优化结构

This commit is contained in:
Yvan 2026-08-20 21:46:52 +08:00
parent 23bc29ce0f
commit 63dbb4cfbc
78 changed files with 775 additions and 1090 deletions

View File

@ -13,7 +13,12 @@ internal/conf/ Config proto; generated by `make config`.
internal/server/ HTTP/gRPC server wiring. internal/server/ HTTP/gRPC server wiring.
internal/service/ Transport adapters; one file per resource. internal/service/ Transport adapters; one file per resource.
internal/biz/ Domain models, usecases, repo interfaces, errors. 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 ## 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. never `data`. The repo interface declared here is the inversion seam.
- `data` imports `biz` to implement the repo interface. Never `service`, - `data` imports `biz` to implement the repo interface. Never `service`,
never DTOs. 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. - `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 A change crossing these arrows the wrong way is a layering bug; fix the

View File

@ -10,10 +10,10 @@ import (
"strings" "strings"
"kra/internal/conf" "kra/internal/conf"
"kra/internal/logging"
"kra/internal/service" "kra/internal/service"
"kra/internal/service/dto" "kra/internal/service/dto"
"kra/internal/worker" "kra/internal/worker"
"kra/pkg/logging"
"github.com/go-kratos/kratos/v3" "github.com/go-kratos/kratos/v3"
"github.com/go-kratos/kratos/v3/config" "github.com/go-kratos/kratos/v3/config"

View File

@ -11,10 +11,13 @@ import (
"kra/internal/biz" "kra/internal/biz"
"kra/internal/conf" "kra/internal/conf"
"kra/internal/data" "kra/internal/data"
"kra/internal/initialize"
"kra/internal/integration"
"kra/internal/integration/cache"
"kra/internal/logging"
"kra/internal/server" "kra/internal/server"
"kra/internal/service" "kra/internal/service"
"kra/internal/worker" "kra/internal/worker"
"kra/pkg/logging"
"github.com/go-kratos/kratos/v3" "github.com/go-kratos/kratos/v3"
"github.com/google/wire" "github.com/google/wire"
@ -22,5 +25,16 @@ import (
// wireApp init kratos application. // wireApp init kratos application.
func wireApp(*conf.Server, *conf.Runtime, *slog.Logger, *logging.ReloadableLogger, string) (*kratos.App, func(), error) { 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,
))
} }

View File

@ -13,14 +13,15 @@ import (
"kra/internal/data" "kra/internal/data"
"kra/internal/data/payment" "kra/internal/data/payment"
"kra/internal/data/system" "kra/internal/data/system"
"kra/internal/initialize"
"kra/internal/integration/cache" "kra/internal/integration/cache"
"kra/internal/integration/email" "kra/internal/integration/email"
"kra/internal/integration/storage" "kra/internal/integration/storage"
"kra/internal/logging"
"kra/internal/server" "kra/internal/server"
"kra/internal/server/handler" "kra/internal/server/handler"
"kra/internal/service" "kra/internal/service"
"kra/internal/worker" "kra/internal/worker"
"kra/pkg/logging"
"log/slog" "log/slog"
) )
@ -123,7 +124,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
tokenIssuer := system.NewTokenIssuer(runtimeSettings) tokenIssuer := system.NewTokenIssuer(runtimeSettings)
tokenService := service.NewTokenService(tokenUsecase, tokenIssuer) tokenService := service.NewTokenService(tokenUsecase, tokenIssuer)
apiToken := handler.NewAPIToken(tokenService) apiToken := handler.NewAPIToken(tokenService)
initializationRepo := data.NewInitializationRepo(dataData) initializationRepo := initialize.NewRepo(dataData)
systemConfigUsecase := biz.NewSystemConfigUsecase(initializationRepo, taskRuntime) systemConfigUsecase := biz.NewSystemConfigUsecase(initializationRepo, taskRuntime)
systemConfigService := service.NewSystemConfigService(systemConfigUsecase, runtimeSettings) systemConfigService := service.NewSystemConfigService(systemConfigUsecase, runtimeSettings)
securityRepo := system.NewSecurityRepo(dataData) securityRepo := system.NewSecurityRepo(dataData)

View File

@ -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
}

View File

@ -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 persistence. Provider SDK implementations are separate in
`internal/integration/payment`. `internal/integration/payment`.
- `internal/data/migration` contains the version-table runner used by the - `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: The root package intentionally keeps only cross-cutting infrastructure:
database lifecycle/reloads, runtime configuration persistence, data-scope 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 Repositories depend on narrow module seams (`system.Provider` and
`payment.Provider`) rather than importing the root implementation details. `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, Non-database adapters remain under `internal/integration` (storage, email,
payment SDKs, and cache), while reusable GORM and pagination helpers live in payment SDKs, and cache). Their constructors are provided by
`pkg/gormkit` and `pkg/pagination`. `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.

View File

@ -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 = "******"
}
}
}

View File

@ -11,12 +11,9 @@ import (
"github.com/google/wire" "github.com/google/wire"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
"kra/internal/biz"
"kra/internal/conf" "kra/internal/conf"
datapayment "kra/internal/data/payment" datapayment "kra/internal/data/payment"
datasystem "kra/internal/data/system" datasystem "kra/internal/data/system"
"kra/internal/integration/cache"
"kra/internal/integration/email"
"kra/internal/integration/storage" "kra/internal/integration/storage"
) )
@ -25,15 +22,13 @@ var ProviderSet = wire.NewSet(
wire.Bind(new(datasystem.Provider), new(*Data)), wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
wire.Bind(new(datapayment.Provider), 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.NewRuntimeSettings,
datasystem.NewTokenIssuer, 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.NewMenuRepo, datasystem.NewDepartmentRepo, datasystem.NewPositionRepo, datasystem.NewDictionaryRepo, datasystem.NewParameterRepo, datasystem.NewAPITokenRepo,
datasystem.NewSecurityRepo, datasystem.NewSecurityRepo,
datasystem.NewVersionRepo, datasystem.NewExportRepo, datasystem.NewAuditRepo, datasystem.NewAuditRecorderRepo, datasystem.NewLogFileRepo, datasystem.NewTaskRepo, 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 { type Data struct {

View File

@ -20,7 +20,7 @@ import (
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"gorm.io/gorm/schema" "gorm.io/gorm/schema"
"kra/internal/conf" "kra/internal/conf"
"kra/pkg/gormkit" "kra/internal/data/gormkit"
) )
var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`) var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`)

View File

@ -8,8 +8,8 @@ import (
"testing" "testing"
"time" "time"
"kra/pkg/gormkit" "kra/internal/data/gormkit"
"kra/pkg/logging" "kra/internal/logging"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )

View File

@ -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.

View File

@ -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}
}

View File

@ -7,17 +7,58 @@ import (
"kra/internal/biz" "kra/internal/biz"
"kra/internal/conf" "kra/internal/conf"
datasystem "kra/internal/data/system"
"kra/internal/integration/storage" "kra/internal/integration/storage"
"github.com/google/uuid" "github.com/google/uuid"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"gorm.io/gorm"
) )
func (r *initializationRepo) PersistConfig(context.Context) error { return r.data.persistConfig() } func (d *Data) RuntimeValues() (*conf.Data, *conf.AdminBackend) { return d.runtime.Values() }
func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte) error { func (d *Data) RuntimeAdmin() *conf.AdminBackend { return d.runtime.Admin() }
currentData, currentAdmin := r.data.runtime.Values() 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) next := proto.Clone(currentAdmin).(*conf.AdminBackend)
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil { if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil {
return err return err
@ -33,25 +74,25 @@ func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte)
if err != nil { if err != nil {
return err return err
} }
if err := r.data.persistStorageIntegrationConfig(ctx, next.Storage); err != nil { if err := d.persistStorageIntegrationConfig(ctx, next.Storage); err != nil {
return err return err
} }
if err := r.data.persistEmailIntegrationConfig(ctx, next.Email); err != nil { if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
return err return err
} }
if err := r.data.persistConfigValues(currentData, next); err != nil { if err := d.persistConfigValues(currentData, next); err != nil {
return err return err
} }
// Writing through the management API updates the same in-memory values // Writing through the management API updates the same in-memory values
// immediately; the file watcher remains the fallback for external edits. // immediately; the file watcher remains the fallback for external edits.
r.data.runtime.Replace(currentData, next) d.runtime.Replace(currentData, next)
if r.data.storage != nil { if d.storage != nil {
r.data.storage.Replace(candidateStorage) d.storage.Replace(candidateStorage)
} }
return nil return nil
} }
func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error { func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error {
currentData, currentAdmin := r.data.runtime.Values() currentData, currentAdmin := d.runtime.Values()
nextData := proto.Clone(currentData).(*conf.Data) nextData := proto.Clone(currentData).(*conf.Data)
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend) nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend)
options := protojson.UnmarshalOptions{DiscardUnknown: true} options := protojson.UnmarshalOptions{DiscardUnknown: true}
@ -72,32 +113,35 @@ func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw,
if err != nil { if err != nil {
return err return err
} }
if err := r.data.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil { if err := d.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil {
return err return err
} }
if err := r.data.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil { if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
return err return err
} }
if err := r.data.persistConfigValues(nextData, nextAdmin); err != nil { if err := d.persistConfigValues(nextData, nextAdmin); err != nil {
return err return err
} }
r.data.runtime.Replace(nextData, nextAdmin) d.runtime.Replace(nextData, nextAdmin)
if r.data.storage != nil { if d.storage != nil {
r.data.storage.Replace(candidateStorage) d.storage.Replace(candidateStorage)
} }
return nil return nil
} }
func (r *initializationRepo) ReloadConfig(ctx context.Context) error { func (d *Data) ReloadConfig(ctx context.Context) error {
return r.data.reloadConfig(ctx) return d.reloadConfig(ctx)
} }
func (r *initializationRepo) IsInitialized(ctx context.Context) (bool, error) { func (d *Data) IsInitialized(context.Context) (bool, error) {
return r.data.databaseReady.Load(), nil 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{} 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 = proto.Clone(current.Database).(*conf.Data_Database)
} }
config.Driver = input.Driver config.Driver = input.Driver
@ -114,16 +158,16 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
return err return err
} }
config.Source = source config.Source = source
r.data.initMu.Lock() d.initMu.Lock()
defer r.data.initMu.Unlock() defer d.initMu.Unlock()
initialized, err := r.IsInitialized(ctx) initialized, err := d.IsInitialized(ctx)
if err != nil { if err != nil {
return err return err
} }
if initialized { if initialized {
return errors.New("数据库已初始化,无需重复初始化") 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 { if err != nil {
return err return err
} }
@ -139,10 +183,12 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
if err := migrateAll(db); err != nil { if err := migrateAll(db); err != nil {
return err return err
} }
if err := datasystem.SeedSystem(ctx, db, input); err != nil { if seed != nil {
if err := seed(ctx, db); err != nil {
return err return err
} }
currentAdmin := r.data.runtime.Admin() }
currentAdmin := d.runtime.Admin()
var legacyStorage *conf.AdminBackend_Storage var legacyStorage *conf.AdminBackend_Storage
if currentAdmin != nil { if currentAdmin != nil {
legacyStorage = currentAdmin.Storage 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) return fmt.Errorf("initialize email integration configuration: %w", err)
} }
signingKey := uuid.NewString() 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) return fmt.Errorf("persist database configuration: %w", err)
} }
r.data.activateDatabase(candidate, config) d.activateDatabase(candidate, config)
currentData, currentAdmin := r.data.runtime.Values() currentData, currentAdmin := d.runtime.Values()
if currentAdmin == nil { if currentAdmin == nil {
currentAdmin = &conf.AdminBackend{} currentAdmin = &conf.AdminBackend{}
} }
@ -174,7 +220,7 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
currentAdmin.Jwt.SigningKey = signingKey currentAdmin.Jwt.SigningKey = signingKey
currentAdmin.Storage = storageConfig currentAdmin.Storage = storageConfig
currentAdmin.Email = emailConfig currentAdmin.Email = emailConfig
r.data.runtime.Replace(currentData, currentAdmin) d.runtime.Replace(currentData, currentAdmin)
activated = true activated = true
return nil return nil
} }

View File

@ -35,9 +35,8 @@ func TestDatabaseConnectionConfigured(t *testing.T) {
func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) { func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
data := &Data{} data := &Data{}
repo := &initializationRepo{data: data}
initialized, err := repo.IsInitialized(context.Background()) initialized, err := data.IsInitialized(context.Background())
if err != nil { if err != nil {
t.Fatalf("IsInitialized() error = %v", err) t.Fatalf("IsInitialized() error = %v", err)
} }
@ -46,7 +45,7 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
} }
data.databaseReady.Store(true) data.databaseReady.Store(true)
initialized, err = repo.IsInitialized(context.Background()) initialized, err = data.IsInitialized(context.Background())
if err != nil { if err != nil {
t.Fatalf("IsInitialized() error = %v", err) t.Fatalf("IsInitialized() error = %v", err)
} }

View File

@ -267,8 +267,7 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
repo := &initializationRepo{data: d} if err = d.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil {
if err = repo.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -1,6 +1,6 @@
// Package migration owns the application database migration runner. Schema // Package migration owns the application database migration runner. Each data
// steps remain in internal/data because they need that package's private POs; // module declares its own steps; the root data package only orders and runs
// this package contains only the reusable versioning mechanism. // them.
package migration package migration
import ( import (

View File

@ -11,21 +11,13 @@ import (
// migrateAll is the single data-layer migration entry point. Module-specific // migrateAll is the single data-layer migration entry point. Module-specific
// schema work stays with the module that owns its persistent objects. // schema work stays with the module that owns its persistent objects.
func migrateAll(db *gorm.DB) error { func migrateAll(db *gorm.DB) error {
return migration.Run(db, []migration.Step{ steps := []migration.Step{{
{ID: "202608200001_baseline", Migrate: func(db *gorm.DB) error { ID: "202608200001_data_infrastructure",
if err := datasystem.LegacySchemaMigration(db); err != nil { Migrate: func(db *gorm.DB) error {
return err return db.AutoMigrate(&integrationConfigPO{})
} },
return datapayment.Migrate(db) }}
}}, steps = append(steps, datasystem.Migrations()...)
{ID: "202608200002_data_reconcile", Migrate: func(db *gorm.DB) error { steps = append(steps, datapayment.Migrations()...)
if err := datapayment.Reconcile(db); err != nil { return migration.Run(db, steps)
return err
}
return datasystem.CurrentDataMigration(db)
}},
{ID: "202608200003_payment_admin_surface", Migrate: func(db *gorm.DB) error {
return datasystem.EnsureAdminSurface(db, datapayment.AdminSurface())
}},
})
} }

View File

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

View File

@ -1,5 +1,5 @@
// Package pagination contains database-agnostic page calculations shared by // Package pagination contains GORM pagination helpers shared by repository
// repository implementations. // implementations in the data layer.
package pagination package pagination
import "gorm.io/gorm" import "gorm.io/gorm"

View File

@ -1,31 +1,31 @@
package payment package payment
import ( import (
"kra/internal/data/system" "kra/internal/adminsurface"
"kra/internal/data/migration"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Migrate creates the persistence owned by the payment module. func Migrations() []migration.Step {
func Migrate(db *gorm.DB) error { return []migration.Step{
return db.AutoMigrate(&integrationConfigPO{}, &paymentOrderPO{}) {ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error {
return db.AutoMigrate(&paymentOrderPO{})
}},
{ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs},
} }
// Reconcile seeds disabled configuration rows for all built-in providers.
func Reconcile(db *gorm.DB) error {
return ensurePaymentIntegrationConfigs(db)
} }
// AdminSurface describes the payment-owned entries shown in the system // AdminSurface describes the payment-owned entries shown in the system
// administration UI. The system module persists these records because it owns // administration UI. The system module persists these records because it owns
// the menu/API/policy tables. // the menu/API/policy tables.
func AdminSurface() system.AdminSurface { func AdminSurface() adminsurface.Surface {
return system.AdminSurface{ return adminsurface.Surface{
Menus: []system.AdminMenu{ Menus: []adminsurface.Menu{
{Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6}, {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}, {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/configs", Method: "GET", APIGroup: "支付", Description: "获取支付渠道配置"},
{Path: "/payment/config", Method: "POST", APIGroup: "支付", Description: "保存支付渠道配置"}, {Path: "/payment/config", Method: "POST", APIGroup: "支付", Description: "保存支付渠道配置"},
{Path: "/payment/orders", Method: "GET", APIGroup: "支付", Description: "分页查询支付订单"}, {Path: "/payment/orders", Method: "GET", APIGroup: "支付", Description: "分页查询支付订单"},

View File

@ -1,6 +1,6 @@
package payment package payment
import "kra/pkg/paymentkit" import "kra/internal/utils/paymentutil"
func text(values map[string]any, key string) string { func text(values map[string]any, key string) string {
value, _ := values[key].(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 // 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 { func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentkit.ConfiguredInt64(values, key, fallback) return paymentutil.ConfiguredInt64(values, key, fallback)
} }

View File

@ -11,7 +11,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
) )
type paymentOrderPO struct { type paymentOrderPO struct {

View File

@ -6,7 +6,6 @@ import (
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"kra/internal/biz"
) )
type Data struct{ gormDB *reloadableDB } type Data struct{ gormDB *reloadableDB }
@ -49,19 +48,13 @@ func openIntegrationConfigTestDB(t *testing.T) *gorm.DB {
} }
func migrateAll(db *gorm.DB) error { func migrateAll(db *gorm.DB) error {
if err := db.AutoMigrate(&integrationConfigPO{}, &paymentOrderPO{}); err != nil { if err := db.AutoMigrate(&integrationConfigPO{}); err != nil {
return err return err
} }
for _, provider := range biz.SupportedPaymentProviders { for _, step := range Migrations() {
var count int64 if err := step.Migrate(db); err != nil {
if err := db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, provider).Count(&count).Error; err != nil {
return err return err
} }
if count == 0 {
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Config: "{}"}).Error; err != nil {
return err
}
}
} }
return nil return nil
} }

View File

@ -6,8 +6,8 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/gormkit" "kra/internal/data/gormkit"
"kra/pkg/pagination" "kra/internal/data/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -23,7 +23,7 @@ type announcementPO struct {
Attachments gormkit.JSON Attachments gormkit.JSON
} }
func (announcementPO) TableName() string { return "kra_announcements_info" } func (announcementPO) TableName() string { return "sys_announcements" }
type announcementRepo struct{ data Provider } type announcementRepo struct{ data Provider }

View File

@ -7,7 +7,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@ -6,7 +6,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@ -10,31 +10,6 @@ type IgnoredAPI struct {
Path string 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 { func DefaultIgnoredAPIs(staticPath string) []IgnoredAPI {
staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath" staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath"
return []IgnoredAPI{ return []IgnoredAPI{

View File

@ -5,7 +5,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@ -8,7 +8,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@ -5,7 +5,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@ -5,7 +5,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@ -1,23 +1,19 @@
package system package system
import ( import (
"errors" "kra/internal/data/migration"
"fmt"
"strings"
"time"
"gorm.io/gorm" "gorm.io/gorm"
) )
// legacySchemaMigration creates the complete schema and repairs legacy table // Migrations returns schema steps owned by the built-in system module.
// shapes. It is intentionally called only by the one-time baseline migration. // Administrators, menus, APIs, and policies are created only by SeedSystem
func LegacySchemaMigration(db *gorm.DB) error { // during the explicit first-install flow.
if err := migrateLegacyIgnoreAPITable(db); err != nil { func Migrations() []migration.Step {
return err return []migration.Step{
} {
if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil { ID: "202608200002_system_schema",
return err Migrate: func(db *gorm.DB) error {
}
return db.AutoMigrate( return db.AutoMigrate(
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{}, &userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{}, &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
@ -28,480 +24,7 @@ func LegacySchemaMigration(db *gorm.DB) error {
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{}, &taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
&announcementPO{}, &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(&current).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(&current).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(&current).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(&current).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(&current).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(&current).Error; err != nil {
return err
}
exists, err := policyExists(tx, 888, current.Path, current.Method)
if err != nil {
return err
}
if !exists {
if err := tx.Create(&casbinRulePO{Ptype: "p", V0: "888", V1: current.Path, V2: current.Method}).Error; err != nil {
return err
}
}
}
return nil
})
}
// migrateLegacyAuthorityDepartmentColumns preserves data created by early
// Kra builds, which used shortened join-column names and a composite primary
// key. The administration connection model has neither a primary key nor a
// uniqueness constraint, so rebuild the small table before AutoMigrate.
func migrateLegacyAuthorityDepartmentColumns(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
const (
table = "sys_authority_departments"
backup = "sys_authority_departments_kra_legacy"
)
// MySQL and Oracle auto-commit DDL. If a prior process stopped between the
// rename and cleanup steps, restore the untouched backup first and retry the
// migration from a known state.
if clean.Migrator().HasTable(backup) {
if clean.Migrator().HasTable(table) {
if err := clean.Migrator().DropTable(table); err != nil {
return fmt.Errorf("remove incomplete authority-department table: %w", err)
}
}
if err := clean.Migrator().RenameTable(backup, table); err != nil {
return fmt.Errorf("restore authority-department backup: %w", err)
}
}
if !clean.Migrator().HasTable(table) {
return nil
}
authorityColumn := "sys_authority_authority_id"
if !tableHasColumn(clean, table, authorityColumn) {
if !tableHasColumn(clean, table, "authority_id") {
return fmt.Errorf("authority-department table has no authority column")
}
authorityColumn = "authority_id"
}
departmentColumn := "sys_department_id"
if !tableHasColumn(clean, table, departmentColumn) {
if !tableHasColumn(clean, table, "department_id") {
return fmt.Errorf("authority-department table has no department column")
}
departmentColumn = "department_id"
}
hasPrimaryKey, err := tableHasPrimaryKey(clean, table)
if err != nil {
return err
}
if authorityColumn == "sys_authority_authority_id" && departmentColumn == "sys_department_id" && !hasPrimaryKey {
return nil
}
type relation struct {
AuthorityID uint `gorm:"column:authority_id"`
DepartmentID uint `gorm:"column:department_id"`
}
var rows []relation
selectColumns := authorityColumn + " AS authority_id, " + departmentColumn + " AS department_id"
if err := clean.Table(table).Select(selectColumns).Scan(&rows).Error; err != nil {
return fmt.Errorf("read legacy authority-department rows: %w", err)
}
rebuild := func(tx *gorm.DB) error {
if err := tx.Migrator().RenameTable(table, backup); err != nil {
return fmt.Errorf("rename legacy authority-department table: %w", err)
}
if err := tx.AutoMigrate(&authorityDepartmentPO{}); err != nil {
return fmt.Errorf("create authority-department table: %w", err)
}
if len(rows) > 0 {
items := make([]authorityDepartmentPO, 0, len(rows))
for _, row := range rows {
items = append(items, authorityDepartmentPO{AuthorityID: row.AuthorityID, DepartmentID: row.DepartmentID})
}
if err := tx.Create(&items).Error; err != nil {
return fmt.Errorf("copy authority-department rows: %w", err)
}
}
var count int64
if err := tx.Model(&authorityDepartmentPO{}).Count(&count).Error; err != nil {
return fmt.Errorf("verify authority-department rows: %w", err)
}
if count != int64(len(rows)) {
return fmt.Errorf("verify authority-department rows: got %d want %d", count, len(rows))
}
if err := tx.Migrator().DropTable(backup); err != nil {
return fmt.Errorf("drop legacy authority-department table: %w", err)
}
return nil
}
switch clean.Dialector.Name() {
case "mysql", "oracle":
if err := rebuild(clean); err != nil {
restoreErr := restoreAuthorityDepartmentBackup(clean, table, backup)
if restoreErr != nil {
return fmt.Errorf("%v; restore authority-department backup: %w", err, restoreErr)
}
return err
}
return nil
default:
return clean.Transaction(rebuild)
}
}
func restoreAuthorityDepartmentBackup(db *gorm.DB, table, backup string) error {
if db.Migrator().HasTable(table) {
if err := db.Migrator().DropTable(table); err != nil {
return err
}
}
if db.Migrator().HasTable(backup) {
return db.Migrator().RenameTable(backup, table)
}
return nil
}
func tableHasPrimaryKey(db *gorm.DB, table string) (bool, error) {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false, err
}
for _, column := range columns {
if primary, ok := column.PrimaryKey(); ok && primary {
return true, nil
}
}
return false, nil
}
// Older builds used a status label outside the administration page's supported
// state set, so normalize existing rows during migration.
func normalizeErrorRecordStatuses(db *gorm.DB) error {
return db.Session(&gorm.Session{NewDB: true}).Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error
}
// migrateLegacyAuthorityAPIsToCasbinRules upgrades the early Kra join-table
// representation to the independent Casbin policy table. Keep the legacy
// table in place for backwards compatibility, but make casbin_rule the sole
// live policy source. Existing policy rows are not duplicated.
func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&authorityAPIPO{}) || !clean.Migrator().HasTable(&casbinRulePO{}) {
return nil
}
type legacyPolicy struct {
AuthorityID uint
Path string
Method string
}
baseQuery := func() *gorm.DB {
return clean.Table("sys_authority_apis sa").
Select("sa.authority_id, a.path, a.method").
Joins("JOIN sys_apis a ON a.id = sa.api_id")
}
query := baseQuery()
// Early Kra schemas stored sys_apis without soft-delete timestamps. The
// legacy-policy migration must run before assuming that column exists;
// otherwise an upgrade from those schemas cannot start on MySQL.
if tableHasColumn(clean, "sys_apis", "deleted_at") {
query = query.Where("a.deleted_at IS NULL")
}
var rows []legacyPolicy
if err := query.Find(&rows).Error; err != nil {
// A few MySQL-compatible drivers report stale/incomplete metadata from
// INFORMATION_SCHEMA during startup. If the optional soft-delete column
// was reported present but the join still rejects it, retry using only
// columns shared by every legacy schema. This migration must never block
// startup of an older database solely because deleted_at is absent.
if strings.Contains(strings.ToLower(err.Error()), "unknown column") && strings.Contains(strings.ToLower(err.Error()), "deleted_at") {
if retryErr := baseQuery().Find(&rows).Error; retryErr != nil {
return retryErr
}
} else {
return err
}
}
for _, row := range rows {
exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method)
if err != nil {
return err
}
if exists {
continue
}
if err := clean.Create(&casbinRulePO{Ptype: "p", V0: fmt.Sprint(row.AuthorityID), V1: row.Path, V2: row.Method}).Error; err != nil {
return err
}
}
return nil
}
// tableHasColumn deliberately inspects the physical table rather than the
// model schema. Legacy databases may predate soft-delete columns even though
// the current PO includes gorm.DeletedAt. Metadata inspection failures are
// treated as "unknown" so callers use the portable query shape.
func tableHasColumn(db *gorm.DB, table, column string) bool {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false
}
for _, item := range columns {
if strings.EqualFold(item.Name(), column) {
return true
}
}
return false
}
// migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key
// shape (path, method) to the compatible model shape (ID/timestamps/soft
// delete). AutoMigrate can add columns but cannot replace an existing
// composite primary key portably, so rebuild the small table once while
// preserving every existing ignore rule.
func migrateLegacyIgnoreAPITable(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&ignoredAPIPO{}) || clean.Migrator().HasColumn(&ignoredAPIPO{}, "id") {
return nil
}
legacyTable := fmt.Sprintf("sys_ignore_apis_legacy_%d", time.Now().UnixNano())
type legacyIgnoredAPI struct {
Path string
Method string
}
return clean.Transaction(func(tx *gorm.DB) error {
if err := tx.Migrator().RenameTable(ignoredAPIPO{}.TableName(), legacyTable); err != nil {
return fmt.Errorf("rename legacy ignore API table: %w", err)
}
if err := tx.AutoMigrate(&ignoredAPIPO{}); err != nil {
return fmt.Errorf("create compatible ignore API table: %w", err)
}
var rows []legacyIgnoredAPI
if err := tx.Table(legacyTable).Find(&rows).Error; err != nil {
return fmt.Errorf("read legacy ignore API rows: %w", err)
}
if len(rows) > 0 {
items := make([]ignoredAPIPO, 0, len(rows))
for _, row := range rows {
items = append(items, ignoredAPIPO{Path: row.Path, Method: row.Method})
}
if err := tx.Create(&items).Error; err != nil {
return fmt.Errorf("copy legacy ignore API rows: %w", err)
}
}
if err := tx.Migrator().DropTable(legacyTable); err != nil {
return fmt.Errorf("drop legacy ignore API table: %w", err)
}
return nil
})
}
// reconcileRootAuthorityAPIs is a one-time upgrade path from the former Kra
// implementation where authority 888 bypassed policy storage entirely. The compatible behavior
// grants its root role through persisted Casbin policies, so when a legacy
// database has the root role but no stored API links, materialize the same
// policy set and let normal authorization read it thereafter.
func reconcileRootAuthorityAPIs(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
var authorityCount int64
if err := clean.Session(&gorm.Session{NewDB: true}).Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil || authorityCount == 0 {
return err
}
var policyCount int64
if err := policyScope(clean).Where("v0 = ?", "888").Count(&policyCount).Error; err != nil || policyCount != 0 {
return err
}
var ignored []ignoredAPIPO
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&ignored).Error; err != nil {
return err
}
ignoreSet := make(map[string]struct{}, len(ignored))
for _, item := range ignored {
ignoreSet[item.Method+"\x00"+item.Path] = struct{}{}
}
var apis []apiPO
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&apis).Error; err != nil {
return err
}
rules := make([]casbinRulePO, 0, len(apis))
for _, api := range apis {
if _, ok := ignoreSet[api.Method+"\x00"+api.Path]; ok {
continue
}
rules = append(rules, newPolicyRule(888, api.Path, api.Method))
}
if len(rules) == 0 {
return nil
}
return clean.Session(&gorm.Session{NewDB: true}).Create(&rules).Error
}
// reconcileReferenceIndexes removes constraints created by older Kra builds
// that are not part of the administration data model. Business services own
// duplicate checks and their user-facing error messages.
func reconcileReferenceIndexes(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
obsolete := []struct {
model any
name string
}{
{&apiPO{}, "idx_api_path_method"},
{&dictionaryPO{}, "idx_sys_dictionaries_type"},
{&parameterPO{}, "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
} }

View File

@ -1,265 +1,31 @@
package system package system
import ( import "testing"
"testing"
"kra/internal/data/migration" func TestMigrationsCreateOnlySystemSchema(t *testing.T) {
)
func TestMigrateAllUsesVersionTableAndIsIdempotent(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil { if err != nil {
t.Fatal(err) 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 { 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) t.Fatal(err)
} }
if count != 2 { for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis", "sys_departments", "sys_announcements"} {
t.Fatalf("migration rows = %d, want 2", count) if !db.Migrator().HasTable(table) {
t.Fatalf("system migration did not create %s", table)
} }
} }
var users, menus, apis int64
func TestEnsurePaymentAdminSurfaceIsIdempotent(t *testing.T) { if err = db.Model(&userPO{}).Count(&users).Error; err != nil {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
sqlDB, err := db.DB() if err = db.Model(&menuPO{}).Count(&menus).Error; err != nil {
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { _ = sqlDB.Close() }) if err = db.Model(&apiPO{}).Count(&apis).Error; err != nil {
if err = db.AutoMigrate(&menuPO{}, &authorityMenuPO{}, &authorityPO{}, &apiPO{}, &casbinRulePO{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
rootParentID := uint(0) if users != 0 || menus != 0 || apis != 0 {
if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root", ParentID: &rootParentID}).Error; err != nil { t.Fatalf("schema migration seeded business data: users=%d menus=%d apis=%d", users, menus, apis)
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)
} }
} }

View File

@ -5,7 +5,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@ -5,7 +5,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"

View File

@ -8,7 +8,7 @@ import (
"kra/internal/biz" "kra/internal/biz"
"kra/internal/conf" "kra/internal/conf"
"kra/pkg/adminauth" "kra/internal/security/adminauth"
jwt "github.com/golang-jwt/jwt/v5" jwt "github.com/golang-jwt/jwt/v5"
) )

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"time" "time"
"kra/internal/adminsurface"
"kra/internal/biz" "kra/internal/biz"
"github.com/google/uuid" "github.com/google/uuid"
@ -12,7 +13,7 @@ import (
"gorm.io/gorm" "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 { return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
rootParentID := uint(0) rootParentID := uint(0)
authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", ParentID: &rootParentID, DataScope: 1, DefaultRouter: "dashboard"} 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 return err
} }
} }
for _, surface := range surfaces {
if err := seedAdminSurface(tx, surface); err != nil {
return err
}
}
var persisted []menuPO var persisted []menuPO
if err := tx.Order("sort asc, id asc").Find(&persisted).Error; err != nil { if err := tx.Order("sort asc, id asc").Find(&persisted).Error; err != nil {
return err 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 { func defaultMenus() []menuPO {
root := func(path, name, title, icon string, sort int) 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} return menuPO{Path: path, Name: name, Component: "view/routerHolder.vue", Title: title, Icon: icon, Sort: sort}

View File

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

View File

@ -3,8 +3,8 @@ package system
import ( import (
"context" "context"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/gormkit" "kra/internal/data/gormkit"
"kra/pkg/pagination" "kra/internal/data/pagination"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"

View File

@ -8,7 +8,6 @@ import (
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"kra/internal/conf" "kra/internal/conf"
"kra/internal/data/migration"
) )
// Data is a small in-package test harness. Production repositories depend on // 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 { func migrateAll(db *gorm.DB) error {
return migration.Run(db, []migration.Step{ for _, step := range Migrations() {
{ID: "test_baseline", Migrate: LegacySchemaMigration}, if err := step.Migrate(db); err != nil {
{ID: "test_reconcile", Migrate: CurrentDataMigration}, return err
}) }
}
return nil
} }

View File

@ -9,7 +9,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"

View File

@ -7,7 +7,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/pagination" "kra/internal/data/pagination"
) )
type versionPO struct { type versionPO struct {

View File

@ -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.

View File

@ -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
}

View File

@ -1,4 +1,4 @@
package data package initialize
import ( import (
"encoding/json" "encoding/json"

View File

@ -1,11 +1,11 @@
package data package initialize
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"strings"
"kra/internal/conf" "kra/internal/conf"
"kra/internal/utils/configutil"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -27,8 +27,8 @@ type configurationEnvelope struct {
} `json:"email"` } `json:"email"`
} }
func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
dataConfig, adminConfig := r.data.runtime.Values() dataConfig, adminConfig := r.backend.RuntimeValues()
admin := map[string]any{"routerPrefix": ""} admin := map[string]any{"routerPrefix": ""}
email := map[string]any{} email := map[string]any{}
if adminConfig != nil { if adminConfig != nil {
@ -121,8 +121,8 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
return json.Marshal(map[string]any{"config": config}) return json.Marshal(map[string]any{"config": config})
} }
func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error { func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error {
currentData, currentAdmin := r.data.runtime.Values() currentData, currentAdmin := r.backend.RuntimeValues()
if currentAdmin == nil { if currentAdmin == nil {
return nil return nil
} }
@ -137,12 +137,12 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json
} }
options := protojson.UnmarshalOptions{DiscardUnknown: true} options := protojson.UnmarshalOptions{DiscardUnknown: true}
if len(value.Data) > 0 && string(value.Data) != "null" { 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 return err
} }
} }
if len(value.Admin) > 0 && string(value.Admin) != "null" { 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 return err
} }
} }
@ -159,7 +159,7 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json
nextAdmin.Email.Secret = value.Email.Secret nextAdmin.Email.Secret = value.Email.Secret
} }
} }
if err := refreshDatabaseSources(nextData); err != nil { if err := r.backend.RefreshDatabaseSources(nextData); err != nil {
return err return err
} }
nextAdmin.ConfigPath = currentAdmin.ConfigPath nextAdmin.ConfigPath = currentAdmin.ConfigPath
@ -174,8 +174,8 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json
return r.PersistRuntimeConfig(ctx, dataRaw, adminRaw) return r.PersistRuntimeConfig(ctx, dataRaw, adminRaw)
} }
func (r *initializationRepo) DiskMountPoints() []string { func (r *Repo) DiskMountPoints() []string {
config := r.data.runtime.Admin() config := r.backend.RuntimeAdmin()
if config == nil { if config == nil {
return nil return nil
} }
@ -202,120 +202,6 @@ func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend {
return proto.Clone(value).(*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, &current); 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 { func durationString(value *durationpb.Duration) string {
if value == nil { if value == nil {
return "0s" return "0s"

View File

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

View File

@ -0,0 +1,5 @@
package initialize
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewRepo)

View File

@ -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.

View File

@ -21,7 +21,7 @@ import (
"testing" "testing"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/paymentkit" "kra/internal/utils/paymentutil"
gopayAlipay "github.com/go-pay/gopay/alipay" gopayAlipay "github.com/go-pay/gopay/alipay"
gopayDouyin "github.com/go-pay/gopay/douyin" gopayDouyin "github.com/go-pay/gopay/douyin"
@ -177,7 +177,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
t.Errorf("read request: %v", err) t.Errorf("read request: %v", err)
return return
} }
requestValues, err = paymentkit.XMLValues(body) requestValues, err = paymentutil.XMLValues(body)
if err != nil { if err != nil {
t.Errorf("parse request: %v", err) t.Errorf("parse request: %v", err)
return 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 := map[string]string{"return_code": "SUCCESS", "return_msg": "OK", "result_code": "SUCCESS", "prepay_id": "prepay"}
response["sign"] = wechatV2Sign(response, key, "MD5") response["sign"] = wechatV2Sign(response, key, "MD5")
_, _ = w.Write(paymentkit.XMLEncode(response)) _, _ = w.Write(paymentutil.XMLEncode(response))
})) }))
defer server.Close() defer server.Close()
@ -219,7 +219,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
if callbackValues["sign"] != originalSign { if callbackValues["sign"] != originalSign {
t.Fatal("verification mutated source values") 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{ callbackResult, err := (&wechatV2Adapter{}).Callback(context.Background(), &biz.PaymentCallback{Body: callbackBody}, map[string]any{
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key, "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) t.Errorf("read request: %v", err)
return return
} }
requestValues, err = paymentkit.XMLValues(body) requestValues, err = paymentutil.XMLValues(body)
if err != nil { if err != nil {
t.Errorf("parse request: %v", err) t.Errorf("parse request: %v", err)
return return
@ -263,7 +263,7 @@ func TestWechatV2MicropayUsesGoPaySDKAndValidatesIdentities(t *testing.T) {
"transaction_id": "WX-TRANSACTION-1", "total_fee": "123", "cash_fee": "123", "fee_type": "CNY", "transaction_id": "WX-TRANSACTION-1", "total_fee": "123", "cash_fee": "123", "fee_type": "CNY",
} }
response["sign"] = wechatV2Sign(response, key, "MD5") response["sign"] = wechatV2Sign(response, key, "MD5")
_, _ = w.Write(paymentkit.XMLEncode(response)) _, _ = w.Write(paymentutil.XMLEncode(response))
})) }))
defer server.Close() defer server.Close()
@ -344,7 +344,7 @@ func TestWechatV2MicropayRejectsInvalidSuccessResponse(t *testing.T) {
} }
tc.mutate(response) tc.mutate(response)
response["sign"] = wechatV2Sign(response, key, "MD5") response["sign"] = wechatV2Sign(response, key, "MD5")
_, _ = w.Write(paymentkit.XMLEncode(response)) _, _ = w.Write(paymentutil.XMLEncode(response))
})) }))
defer server.Close() defer server.Close()

View File

@ -12,7 +12,7 @@ import (
"testing" "testing"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/paymentkit" "kra/internal/utils/paymentutil"
"github.com/go-pay/gopay" "github.com/go-pay/gopay"
gopayQQ "github.com/go-pay/gopay/qq" gopayQQ "github.com/go-pay/gopay/qq"
@ -55,7 +55,7 @@ func TestQQCreateKeepsClientPaymentDataOutOfDurableIdentities(t *testing.T) {
http.Error(w, "invalid XML", http.StatusBadRequest) http.Error(w, "invalid XML", http.StatusBadRequest)
return return
} }
values, err := paymentkit.XMLValues(body) values, err := paymentutil.XMLValues(body)
if err != nil { if err != nil {
t.Errorf("parse request XML: %v", err) t.Errorf("parse request XML: %v", err)
http.Error(w, "invalid XML", http.StatusBadRequest) http.Error(w, "invalid XML", http.StatusBadRequest)
@ -81,7 +81,7 @@ func TestQQCreateKeepsClientPaymentDataOutOfDurableIdentities(t *testing.T) {
for key, value := range response { for key, value := range response {
encoded[key] = fmt.Sprint(value) encoded[key] = fmt.Sprint(value)
} }
_, _ = w.Write(paymentkit.XMLEncode(encoded)) _, _ = w.Write(paymentutil.XMLEncode(encoded))
})) }))
result, err := (&qqAdapter{}).Create(context.Background(), &biz.PaymentRequest{ result, err := (&qqAdapter{}).Create(context.Background(), &biz.PaymentRequest{

View File

@ -8,7 +8,7 @@ import (
"strings" "strings"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/paymentkit" "kra/internal/utils/paymentutil"
) )
func callbackFields(callback *biz.PaymentCallback) map[string]string { 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 // 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 { func normalizePaymentStatus(value, fallback string) string {
return paymentkit.NormalizeStatus(value, fallback) return paymentutil.NormalizeStatus(value, fallback)
} }
func parseIntegerAmount(value string) (int64, error) { func parseIntegerAmount(value string) (int64, error) {
return paymentkit.ParseIntegerAmount(value) return paymentutil.ParseIntegerAmount(value)
} }
func parseDecimalAmount(value string, scale int64) (int64, error) { 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) { 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 { 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 { func jsonObject(raw []byte) map[string]any {
return paymentkit.JSONObject(raw) return paymentutil.JSONObject(raw)
} }
func nestedString(value any, keys ...string) string { func nestedString(value any, keys ...string) string {
return paymentkit.NestedString(value, keys...) return paymentutil.NestedString(value, keys...)
} }
func valueAtPath(value any, path string) any { func valueAtPath(value any, path string) any {
return paymentkit.ValueAtPath(value, path) return paymentutil.ValueAtPath(value, path)
} }
func stringAtPath(value any, path string) string { 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 { 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 { 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 // parseConfiguredAmount reads a provider amount field using an explicit unit

View File

@ -15,7 +15,7 @@ import (
"time" "time"
"kra/internal/biz" "kra/internal/biz"
"kra/pkg/paymentkit" "kra/internal/utils/paymentutil"
) )
type vendorProfile string 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)) 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[:])) request.Header.Set("Authorization", "OPEN-BODY-SIG AppId="+appID+", Timestamp="+timestamp+", Nonce="+nonceValue+", Signature="+hex.EncodeToString(digest[:]))
case vendorSFT: case vendorSFT:
request.Header.Set("X-SFT-Sign", paymentkit.MD5Canonical(payload, secret)) request.Header.Set("X-SFT-Sign", paymentutil.MD5Canonical(payload, secret))
case vendorWechatGame: 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 != "" { if token := text(c, "access_token"); token != "" {
query := request.URL.Query() query := request.URL.Query()
query.Set("access_token", token) query.Set("access_token", token)
request.URL.RawQuery = query.Encode() request.URL.RawQuery = query.Encode()
} }
case vendorDouyinGame: 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: 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 { 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 values[key] = value
} }
} }
actual = paymentkit.MD5Canonical(values, secret) actual = paymentutil.MD5Canonical(values, secret)
default: default:
actual = paymentkit.HMACSHA256Hex(raw, secret, false) actual = paymentutil.HMACSHA256Hex(raw, secret, false)
} }
if !hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(actual))) { if !hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(actual))) {
return errors.New("支付回调签名校验失败") return errors.New("支付回调签名校验失败")

View File

@ -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)),
)

View File

@ -1,4 +1,4 @@
package osskit package storage
import ( import (
"context" "context"
@ -7,10 +7,10 @@ import (
"io" "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 // MD5 of the exact bytes written. Provider-specific storage clients stay out
// of this package; callbacks keep this helper independent from biz and SDKs. // of this package; callbacks keep this helper independent from biz and SDKs.
func ComposeStreams( func composeStreams(
ctx context.Context, ctx context.Context,
names []string, names []string,
open func(context.Context, string) (io.ReadCloser, error), open func(context.Context, string) (io.ReadCloser, error),

View File

@ -1,4 +1,4 @@
package osskit package storage
import ( import (
"context" "context"
@ -13,7 +13,7 @@ import (
func TestComposeStreams(t *testing.T) { func TestComposeStreams(t *testing.T) {
objects := map[string]string{"a": "hello ", "b": "world"} objects := map[string]string{"a": "hello ", "b": "world"}
var stored string 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] value, ok := objects[name]
if !ok { if !ok {
return nil, errors.New("missing object") return nil, errors.New("missing object")
@ -35,7 +35,7 @@ func TestComposeStreams(t *testing.T) {
func TestComposeStreamsRemovesPartialDestination(t *testing.T) { func TestComposeStreamsRemovesPartialDestination(t *testing.T) {
removed := false 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") return nil, errors.New("open failed")
}, func(_ context.Context, _ string, reader io.Reader) error { }, func(_ context.Context, _ string, reader io.Reader) error {
_, _ = io.ReadAll(reader) _, _ = io.ReadAll(reader)

View File

@ -12,7 +12,6 @@ import (
"kra/internal/biz" "kra/internal/biz"
"kra/internal/conf" "kra/internal/conf"
"kra/pkg/osskit"
) )
type fileStorage struct { 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) { func composeFiles(ctx context.Context, storage biz.FileStorage, names []string, destination string) (*biz.StoredFile, string, error) {
var stored *biz.StoredFile 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 var putErr error
stored, putErr = storage.Put(ctx, destination, reader) stored, putErr = storage.Put(ctx, destination, reader)
return putErr return putErr

View File

@ -50,7 +50,7 @@ func skipStackFile(filename string) bool {
"/go/pkg/mod/", "/go/pkg/mod/",
"/go.uber.org/", "/go.uber.org/",
"/gorm.io/", "/gorm.io/",
"/pkg/logging/", "/internal/logging/",
"/internal/server/middleware/", "/internal/server/middleware/",
"/internal/server/router/", "/internal/server/router/",
} { } {

View File

@ -25,7 +25,7 @@ type Options struct {
} }
// ErrorEntry is the storage-neutral representation of an Error-level log. // 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. // dependency on the application service or persistence layers.
type ErrorEntry struct { type ErrorEntry struct {
Form, Info, Level, RequestID, TraceID string Form, Info, Level, RequestID, TraceID string

View File

@ -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.

View File

@ -5,7 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"strings" "strings"
"kra/pkg/logging" "kra/internal/logging"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"

11
internal/utils/README.md Normal file
View File

@ -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`.

View File

@ -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, &current); 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
}
}

View File

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

View File

@ -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`.

View File

@ -1,4 +1,4 @@
package paymentkit package paymentutil
import ( import (
"errors" "errors"

View File

@ -1,4 +1,4 @@
package paymentkit package paymentutil
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package paymentkit package paymentutil
import ( import (
"encoding/json" "encoding/json"

View File

@ -1,4 +1,4 @@
package paymentkit package paymentutil
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package paymentkit package paymentutil
import ( import (
"crypto/hmac" "crypto/hmac"

View File

@ -1,4 +1,4 @@
package paymentkit package paymentutil
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package paymentkit package paymentutil
import ( import (
"fmt" "fmt"

View File

@ -1,4 +1,4 @@
package paymentkit package paymentutil
import ( import (
"bytes" "bytes"