优化结构
This commit is contained in:
parent
23bc29ce0f
commit
63dbb4cfbc
11
AGENTS.md
11
AGENTS.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 = "******"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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_$-]*$`)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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())
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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{})
|
||||||
|
}},
|
||||||
// Reconcile seeds disabled configuration rows for all built-in providers.
|
{ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs},
|
||||||
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: "分页查询支付订单"},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(¤t).Error
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
|
||||||
if err := tx.Create(&item).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case err != nil:
|
|
||||||
return err
|
|
||||||
default:
|
|
||||||
if err := tx.Model(¤t).Updates(map[string]any{
|
|
||||||
"menu_level": item.MenuLevel, "parent_id": parent.ID, "path": item.Path,
|
|
||||||
"component": item.Component, "title": item.Title, "icon": item.Icon, "sort": item.Sort,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apis := make([]apiPO, 0, len(surface.APIs))
|
|
||||||
for _, item := range surface.APIs {
|
|
||||||
apis = append(apis, apiPO{Path: item.Path, Method: item.Method, APIGroup: item.APIGroup, Description: item.Description})
|
|
||||||
}
|
|
||||||
for _, item := range apis {
|
|
||||||
var current apiPO
|
|
||||||
err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(¤t).Error
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
|
||||||
if err := tx.Create(&item).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case err != nil:
|
|
||||||
return err
|
|
||||||
default:
|
|
||||||
if err := tx.Model(¤t).Updates(map[string]any{"api_group": item.APIGroup, "description": item.Description}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var authorityCount int64
|
|
||||||
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if authorityCount == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, item := range menus {
|
|
||||||
var current menuPO
|
|
||||||
if err := tx.Where("name = ?", item.Name).First(¤t).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
if err := tx.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, current.ID).Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
if err := tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: current.ID}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, item := range apis {
|
|
||||||
var current apiPO
|
|
||||||
if err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(¤t).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
exists, err := policyExists(tx, 888, current.Path, current.Method)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
if err := tx.Create(&casbinRulePO{Ptype: "p", V0: "888", V1: current.Path, V2: current.Method}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrateLegacyAuthorityDepartmentColumns preserves data created by early
|
|
||||||
// Kra builds, which used shortened join-column names and a composite primary
|
|
||||||
// key. The administration connection model has neither a primary key nor a
|
|
||||||
// uniqueness constraint, so rebuild the small table before AutoMigrate.
|
|
||||||
func migrateLegacyAuthorityDepartmentColumns(db *gorm.DB) error {
|
|
||||||
clean := db.Session(&gorm.Session{NewDB: true})
|
|
||||||
const (
|
|
||||||
table = "sys_authority_departments"
|
|
||||||
backup = "sys_authority_departments_kra_legacy"
|
|
||||||
)
|
|
||||||
// MySQL and Oracle auto-commit DDL. If a prior process stopped between the
|
|
||||||
// rename and cleanup steps, restore the untouched backup first and retry the
|
|
||||||
// migration from a known state.
|
|
||||||
if clean.Migrator().HasTable(backup) {
|
|
||||||
if clean.Migrator().HasTable(table) {
|
|
||||||
if err := clean.Migrator().DropTable(table); err != nil {
|
|
||||||
return fmt.Errorf("remove incomplete authority-department table: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := clean.Migrator().RenameTable(backup, table); err != nil {
|
|
||||||
return fmt.Errorf("restore authority-department backup: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !clean.Migrator().HasTable(table) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
authorityColumn := "sys_authority_authority_id"
|
|
||||||
if !tableHasColumn(clean, table, authorityColumn) {
|
|
||||||
if !tableHasColumn(clean, table, "authority_id") {
|
|
||||||
return fmt.Errorf("authority-department table has no authority column")
|
|
||||||
}
|
|
||||||
authorityColumn = "authority_id"
|
|
||||||
}
|
|
||||||
departmentColumn := "sys_department_id"
|
|
||||||
if !tableHasColumn(clean, table, departmentColumn) {
|
|
||||||
if !tableHasColumn(clean, table, "department_id") {
|
|
||||||
return fmt.Errorf("authority-department table has no department column")
|
|
||||||
}
|
|
||||||
departmentColumn = "department_id"
|
|
||||||
}
|
|
||||||
hasPrimaryKey, err := tableHasPrimaryKey(clean, table)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if authorityColumn == "sys_authority_authority_id" && departmentColumn == "sys_department_id" && !hasPrimaryKey {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type relation struct {
|
|
||||||
AuthorityID uint `gorm:"column:authority_id"`
|
|
||||||
DepartmentID uint `gorm:"column:department_id"`
|
|
||||||
}
|
|
||||||
var rows []relation
|
|
||||||
selectColumns := authorityColumn + " AS authority_id, " + departmentColumn + " AS department_id"
|
|
||||||
if err := clean.Table(table).Select(selectColumns).Scan(&rows).Error; err != nil {
|
|
||||||
return fmt.Errorf("read legacy authority-department rows: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rebuild := func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Migrator().RenameTable(table, backup); err != nil {
|
|
||||||
return fmt.Errorf("rename legacy authority-department table: %w", err)
|
|
||||||
}
|
|
||||||
if err := tx.AutoMigrate(&authorityDepartmentPO{}); err != nil {
|
|
||||||
return fmt.Errorf("create authority-department table: %w", err)
|
|
||||||
}
|
|
||||||
if len(rows) > 0 {
|
|
||||||
items := make([]authorityDepartmentPO, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, authorityDepartmentPO{AuthorityID: row.AuthorityID, DepartmentID: row.DepartmentID})
|
|
||||||
}
|
|
||||||
if err := tx.Create(&items).Error; err != nil {
|
|
||||||
return fmt.Errorf("copy authority-department rows: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
if err := tx.Model(&authorityDepartmentPO{}).Count(&count).Error; err != nil {
|
|
||||||
return fmt.Errorf("verify authority-department rows: %w", err)
|
|
||||||
}
|
|
||||||
if count != int64(len(rows)) {
|
|
||||||
return fmt.Errorf("verify authority-department rows: got %d want %d", count, len(rows))
|
|
||||||
}
|
|
||||||
if err := tx.Migrator().DropTable(backup); err != nil {
|
|
||||||
return fmt.Errorf("drop legacy authority-department table: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch clean.Dialector.Name() {
|
|
||||||
case "mysql", "oracle":
|
|
||||||
if err := rebuild(clean); err != nil {
|
|
||||||
restoreErr := restoreAuthorityDepartmentBackup(clean, table, backup)
|
|
||||||
if restoreErr != nil {
|
|
||||||
return fmt.Errorf("%v; restore authority-department backup: %w", err, restoreErr)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return clean.Transaction(rebuild)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreAuthorityDepartmentBackup(db *gorm.DB, table, backup string) error {
|
|
||||||
if db.Migrator().HasTable(table) {
|
|
||||||
if err := db.Migrator().DropTable(table); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if db.Migrator().HasTable(backup) {
|
|
||||||
return db.Migrator().RenameTable(backup, table)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableHasPrimaryKey(db *gorm.DB, table string) (bool, error) {
|
|
||||||
columns, err := db.Migrator().ColumnTypes(table)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
for _, column := range columns {
|
|
||||||
if primary, ok := column.PrimaryKey(); ok && primary {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Older builds used a status label outside the administration page's supported
|
|
||||||
// state set, so normalize existing rows during migration.
|
|
||||||
func normalizeErrorRecordStatuses(db *gorm.DB) error {
|
|
||||||
return db.Session(&gorm.Session{NewDB: true}).Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrateLegacyAuthorityAPIsToCasbinRules upgrades the early Kra join-table
|
|
||||||
// representation to the independent Casbin policy table. Keep the legacy
|
|
||||||
// table in place for backwards compatibility, but make casbin_rule the sole
|
|
||||||
// live policy source. Existing policy rows are not duplicated.
|
|
||||||
func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
|
|
||||||
clean := db.Session(&gorm.Session{NewDB: true})
|
|
||||||
if !clean.Migrator().HasTable(&authorityAPIPO{}) || !clean.Migrator().HasTable(&casbinRulePO{}) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
type legacyPolicy struct {
|
|
||||||
AuthorityID uint
|
|
||||||
Path string
|
|
||||||
Method string
|
|
||||||
}
|
|
||||||
baseQuery := func() *gorm.DB {
|
|
||||||
return clean.Table("sys_authority_apis sa").
|
|
||||||
Select("sa.authority_id, a.path, a.method").
|
|
||||||
Joins("JOIN sys_apis a ON a.id = sa.api_id")
|
|
||||||
}
|
|
||||||
query := baseQuery()
|
|
||||||
// Early Kra schemas stored sys_apis without soft-delete timestamps. The
|
|
||||||
// legacy-policy migration must run before assuming that column exists;
|
|
||||||
// otherwise an upgrade from those schemas cannot start on MySQL.
|
|
||||||
if tableHasColumn(clean, "sys_apis", "deleted_at") {
|
|
||||||
query = query.Where("a.deleted_at IS NULL")
|
|
||||||
}
|
|
||||||
var rows []legacyPolicy
|
|
||||||
if err := query.Find(&rows).Error; err != nil {
|
|
||||||
// A few MySQL-compatible drivers report stale/incomplete metadata from
|
|
||||||
// INFORMATION_SCHEMA during startup. If the optional soft-delete column
|
|
||||||
// was reported present but the join still rejects it, retry using only
|
|
||||||
// columns shared by every legacy schema. This migration must never block
|
|
||||||
// startup of an older database solely because deleted_at is absent.
|
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "unknown column") && strings.Contains(strings.ToLower(err.Error()), "deleted_at") {
|
|
||||||
if retryErr := baseQuery().Find(&rows).Error; retryErr != nil {
|
|
||||||
return retryErr
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := clean.Create(&casbinRulePO{Ptype: "p", V0: fmt.Sprint(row.AuthorityID), V1: row.Path, V2: row.Method}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// tableHasColumn deliberately inspects the physical table rather than the
|
|
||||||
// model schema. Legacy databases may predate soft-delete columns even though
|
|
||||||
// the current PO includes gorm.DeletedAt. Metadata inspection failures are
|
|
||||||
// treated as "unknown" so callers use the portable query shape.
|
|
||||||
func tableHasColumn(db *gorm.DB, table, column string) bool {
|
|
||||||
columns, err := db.Migrator().ColumnTypes(table)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, item := range columns {
|
|
||||||
if strings.EqualFold(item.Name(), column) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key
|
|
||||||
// shape (path, method) to the compatible model shape (ID/timestamps/soft
|
|
||||||
// delete). AutoMigrate can add columns but cannot replace an existing
|
|
||||||
// composite primary key portably, so rebuild the small table once while
|
|
||||||
// preserving every existing ignore rule.
|
|
||||||
func migrateLegacyIgnoreAPITable(db *gorm.DB) error {
|
|
||||||
clean := db.Session(&gorm.Session{NewDB: true})
|
|
||||||
if !clean.Migrator().HasTable(&ignoredAPIPO{}) || clean.Migrator().HasColumn(&ignoredAPIPO{}, "id") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
legacyTable := fmt.Sprintf("sys_ignore_apis_legacy_%d", time.Now().UnixNano())
|
|
||||||
type legacyIgnoredAPI struct {
|
|
||||||
Path string
|
|
||||||
Method string
|
|
||||||
}
|
|
||||||
return clean.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Migrator().RenameTable(ignoredAPIPO{}.TableName(), legacyTable); err != nil {
|
|
||||||
return fmt.Errorf("rename legacy ignore API table: %w", err)
|
|
||||||
}
|
|
||||||
if err := tx.AutoMigrate(&ignoredAPIPO{}); err != nil {
|
|
||||||
return fmt.Errorf("create compatible ignore API table: %w", err)
|
|
||||||
}
|
|
||||||
var rows []legacyIgnoredAPI
|
|
||||||
if err := tx.Table(legacyTable).Find(&rows).Error; err != nil {
|
|
||||||
return fmt.Errorf("read legacy ignore API rows: %w", err)
|
|
||||||
}
|
|
||||||
if len(rows) > 0 {
|
|
||||||
items := make([]ignoredAPIPO, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, ignoredAPIPO{Path: row.Path, Method: row.Method})
|
|
||||||
}
|
|
||||||
if err := tx.Create(&items).Error; err != nil {
|
|
||||||
return fmt.Errorf("copy legacy ignore API rows: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tx.Migrator().DropTable(legacyTable); err != nil {
|
|
||||||
return fmt.Errorf("drop legacy ignore API table: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileRootAuthorityAPIs is a one-time upgrade path from the former Kra
|
|
||||||
// implementation where authority 888 bypassed policy storage entirely. The compatible behavior
|
|
||||||
// grants its root role through persisted Casbin policies, so when a legacy
|
|
||||||
// database has the root role but no stored API links, materialize the same
|
|
||||||
// policy set and let normal authorization read it thereafter.
|
|
||||||
func reconcileRootAuthorityAPIs(db *gorm.DB) error {
|
|
||||||
clean := db.Session(&gorm.Session{NewDB: true})
|
|
||||||
var authorityCount int64
|
|
||||||
if err := clean.Session(&gorm.Session{NewDB: true}).Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil || authorityCount == 0 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var policyCount int64
|
|
||||||
if err := policyScope(clean).Where("v0 = ?", "888").Count(&policyCount).Error; err != nil || policyCount != 0 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var ignored []ignoredAPIPO
|
|
||||||
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&ignored).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ignoreSet := make(map[string]struct{}, len(ignored))
|
|
||||||
for _, item := range ignored {
|
|
||||||
ignoreSet[item.Method+"\x00"+item.Path] = struct{}{}
|
|
||||||
}
|
|
||||||
var apis []apiPO
|
|
||||||
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&apis).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rules := make([]casbinRulePO, 0, len(apis))
|
|
||||||
for _, api := range apis {
|
|
||||||
if _, ok := ignoreSet[api.Method+"\x00"+api.Path]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rules = append(rules, newPolicyRule(888, api.Path, api.Method))
|
|
||||||
}
|
|
||||||
if len(rules) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return clean.Session(&gorm.Session{NewDB: true}).Create(&rules).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileReferenceIndexes removes constraints created by older Kra builds
|
|
||||||
// that are not part of the administration data model. Business services own
|
|
||||||
// duplicate checks and their user-facing error messages.
|
|
||||||
func reconcileReferenceIndexes(db *gorm.DB) error {
|
|
||||||
clean := db.Session(&gorm.Session{NewDB: true})
|
|
||||||
obsolete := []struct {
|
|
||||||
model any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{&apiPO{}, "idx_api_path_method"},
|
|
||||||
{&dictionaryPO{}, "idx_sys_dictionaries_type"},
|
|
||||||
{¶meterPO{}, "idx_sys_params_key"},
|
|
||||||
{&apiTokenPO{}, "idx_sys_api_tokens_token"},
|
|
||||||
{&exportTemplatePO{}, "idx_sys_export_templates_template_id"},
|
|
||||||
}
|
|
||||||
for _, item := range obsolete {
|
|
||||||
migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator()
|
|
||||||
if migrator.HasIndex(item.model, item.name) {
|
|
||||||
if err := migrator.DropIndex(item.model, item.name); err != nil {
|
|
||||||
return fmt.Errorf("drop obsolete index %s: %w", item.name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, item := range []struct {
|
|
||||||
name string
|
|
||||||
field string
|
|
||||||
}{{"idx_sys_users_uuid", "UUID"}, {"idx_sys_users_username", "Username"}} {
|
|
||||||
unique, err := indexIsUnique(clean.Session(&gorm.Session{NewDB: true}), &userPO{}, item.name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !unique {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator()
|
|
||||||
if err = migrator.DropIndex(&userPO{}, item.name); err != nil {
|
|
||||||
return fmt.Errorf("drop legacy unique index %s: %w", item.name, err)
|
|
||||||
}
|
|
||||||
if err = migrator.CreateIndex(&userPO{}, item.field); err != nil {
|
|
||||||
return fmt.Errorf("create reference index %s: %w", item.name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexIsUnique(db *gorm.DB, model any, name string) (bool, error) {
|
|
||||||
indexes, err := db.Migrator().GetIndexes(model)
|
|
||||||
if err != nil {
|
|
||||||
// Some third-party GORM drivers do not implement index inspection.
|
|
||||||
// Fresh schemas are already correct; skip only the legacy repair there.
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
for _, index := range indexes {
|
|
||||||
if index.Name() == name {
|
|
||||||
unique, known := index.Unique()
|
|
||||||
return known && unique, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
func TestEnsurePaymentAdminSurfaceIsIdempotent(t *testing.T) {
|
var users, menus, apis int64
|
||||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
if err = db.Model(&userPO{}).Count(&users).Error; err != nil {
|
||||||
if err != nil {
|
t.Fatal(err)
|
||||||
t.Fatal(err)
|
}
|
||||||
}
|
if err = db.Model(&menuPO{}).Count(&menus).Error; err != nil {
|
||||||
sqlDB, err := db.DB()
|
t.Fatal(err)
|
||||||
if err != nil {
|
}
|
||||||
t.Fatal(err)
|
if err = db.Model(&apiPO{}).Count(&apis).Error; err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
}
|
||||||
if err = db.AutoMigrate(&menuPO{}, &authorityMenuPO{}, &authorityPO{}, &apiPO{}, &casbinRulePO{}); err != nil {
|
if users != 0 || menus != 0 || apis != 0 {
|
||||||
t.Fatal(err)
|
t.Fatalf("schema migration seeded business data: users=%d menus=%d apis=%d", users, menus, apis)
|
||||||
}
|
|
||||||
rootParentID := uint(0)
|
|
||||||
if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root", ParentID: &rootParentID}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = db.Create(&menuPO{Name: "extensions", Path: "legacy-extensions", Title: "旧扩展"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = db.Create(&menuPO{Name: "paymentOrders", Component: "legacy.vue", Title: "旧支付订单"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = db.Create(&apiPO{Path: "/payment/configs", Method: "GET", APIGroup: "legacy"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
if err = ensurePaymentAdminSurface(db); err != nil {
|
|
||||||
t.Fatalf("ensure payment admin surface pass %d: %v", i+1, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var parent menuPO
|
|
||||||
if err = db.Where("name = ?", "extensions").First(&parent).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for name, component := range map[string]string{
|
|
||||||
"paymentOrders": "view/systemTools/payment/orders.vue",
|
|
||||||
"paymentConfig": "view/systemTools/payment/config.vue",
|
|
||||||
} {
|
|
||||||
var menu menuPO
|
|
||||||
if err = db.Where("name = ?", name).First(&menu).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if menu.ParentID != parent.ID || menu.Component != component {
|
|
||||||
t.Fatalf("menu %s = %#v", name, menu)
|
|
||||||
}
|
|
||||||
var linkCount int64
|
|
||||||
if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, menu.ID).Count(&linkCount).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if linkCount != 1 {
|
|
||||||
t.Fatalf("menu %s root links = %d, want 1", name, linkCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, item := range []struct{ method, path string }{
|
|
||||||
{"GET", "/payment/configs"}, {"POST", "/payment/config"},
|
|
||||||
{"GET", "/payment/orders"}, {"POST", "/payment/order"},
|
|
||||||
} {
|
|
||||||
var apiCount int64
|
|
||||||
if err = db.Model(&apiPO{}).Where("path = ? AND method = ?", item.path, item.method).Count(&apiCount).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if apiCount != 1 {
|
|
||||||
t.Fatalf("API %s %s rows = %d, want 1", item.method, item.path, apiCount)
|
|
||||||
}
|
|
||||||
var policyCount int64
|
|
||||||
if err = policyScope(db).Where("v0 = ? AND v1 = ? AND v2 = ?", "888", item.path, item.method).Count(&policyCount).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if policyCount != 1 {
|
|
||||||
t.Fatalf("policy %s %s rows = %d, want 1", item.method, item.path, policyCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateLegacyAuthorityAPIsWithoutDeletedAt(t *testing.T) {
|
|
||||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
||||||
|
|
||||||
for _, statement := range []string{
|
|
||||||
`CREATE TABLE sys_apis (id integer primary key, path text, method text)`,
|
|
||||||
`CREATE TABLE sys_authority_apis (authority_id integer, api_id integer)`,
|
|
||||||
`INSERT INTO sys_apis (id, path, method) VALUES (1, '/legacy', 'GET')`,
|
|
||||||
`INSERT INTO sys_authority_apis (authority_id, api_id) VALUES (888, 1)`,
|
|
||||||
} {
|
|
||||||
if err = db.Exec(statement).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err = db.AutoMigrate(&casbinRulePO{}, &errorRecordPO{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
legacyError := errorRecordPO{Status: "未解决"}
|
|
||||||
if err = db.Create(&legacyError).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil {
|
|
||||||
t.Fatalf("legacy migration failed without sys_apis.deleted_at: %v", err)
|
|
||||||
}
|
|
||||||
if err = normalizeErrorRecordStatuses(db); err != nil {
|
|
||||||
t.Fatalf("following migration inherited the legacy table alias: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
if err = db.Model(&casbinRulePO{}).
|
|
||||||
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", "888", "/legacy", "GET").
|
|
||||||
Count(&count).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("migrated policy count = %d, want 1", count)
|
|
||||||
}
|
|
||||||
if err = db.First(&legacyError, legacyError.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if legacyError.Status != "未处理" {
|
|
||||||
t.Fatalf("normalized status = %q, want 未处理", legacyError.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReconcileRootAuthorityAPIsUsesIndependentQueries(t *testing.T) {
|
|
||||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
||||||
|
|
||||||
if err = db.AutoMigrate(&authorityPO{}, &apiPO{}, &ignoredAPIPO{}, &casbinRulePO{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = db.Create(&apiPO{Path: "/allowed", Method: "GET"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = db.Create(&apiPO{Path: "/ignored", Method: "POST"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = db.Create(&ignoredAPIPO{Path: "/ignored", Method: "POST"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deliberately pass a handle carrying an unrelated model and predicate.
|
|
||||||
// Migration queries must not inherit either state.
|
|
||||||
dirty := db.Model(&authorityPO{}).Where("authority_id = ?", 999)
|
|
||||||
if err = reconcileRootAuthorityAPIs(dirty); err != nil {
|
|
||||||
t.Fatalf("reconcile root policies with dirty DB state: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var rules []casbinRulePO
|
|
||||||
if err = db.Where("ptype = ? AND v0 = ?", "p", "888").Find(&rules).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(rules) != 1 || rules[0].V1 != "/allowed" || rules[0].V2 != "GET" {
|
|
||||||
t.Fatalf("root rules = %#v, want only GET /allowed", rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateLegacyAuthorityDepartmentColumns(t *testing.T) {
|
|
||||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
||||||
|
|
||||||
for _, statement := range []string{
|
|
||||||
`CREATE TABLE sys_authority_departments (authority_id integer, department_id integer, PRIMARY KEY (authority_id, department_id))`,
|
|
||||||
`INSERT INTO sys_authority_departments (authority_id, department_id) VALUES (888, 7)`,
|
|
||||||
} {
|
|
||||||
if err = db.Exec(statement).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err = migrateLegacyAuthorityDepartmentColumns(db); err != nil {
|
|
||||||
t.Fatalf("migrate legacy authority-department columns: %v", err)
|
|
||||||
}
|
|
||||||
if err = db.AutoMigrate(&authorityDepartmentPO{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var relation authorityDepartmentPO
|
|
||||||
if err = db.First(&relation).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if relation.AuthorityID != 888 || relation.DepartmentID != 7 {
|
|
||||||
t.Fatalf("migrated relation = %#v", relation)
|
|
||||||
}
|
|
||||||
for _, column := range []string{"sys_authority_authority_id", "sys_department_id"} {
|
|
||||||
if !tableHasColumn(db, "sys_authority_departments", column) {
|
|
||||||
t.Fatalf("missing compatible column %s", column)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hasPrimaryKey, err := tableHasPrimaryKey(db, "sys_authority_departments")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if hasPrimaryKey {
|
|
||||||
t.Fatal("authority-department table retained the legacy primary key")
|
|
||||||
}
|
|
||||||
if err = db.Create(&authorityDepartmentPO{AuthorityID: 888, DepartmentID: 7}).Error; err != nil {
|
|
||||||
t.Fatalf("duplicate administration relation was rejected: %v", err)
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
if err = db.Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ? AND sys_department_id = ?", 888, 7).Count(&count).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if count != 2 {
|
|
||||||
t.Fatalf("duplicate relation count = %d, want 2", count)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package data
|
package initialize
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -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, ¤t); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var incoming map[string]any
|
|
||||||
if err = json.Unmarshal(patch, &incoming); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
incoming = normalizeJSONKeys(incoming).(map[string]any)
|
|
||||||
mergeJSONObjects(current, incoming)
|
|
||||||
merged, err := json.Marshal(current)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return options.Unmarshal(merged, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeJSONKeys(value any) any {
|
|
||||||
switch item := value.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
result := make(map[string]any, len(item))
|
|
||||||
for key, nested := range item {
|
|
||||||
result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
case []any:
|
|
||||||
result := make([]any, len(item))
|
|
||||||
for index, nested := range item {
|
|
||||||
result[index] = normalizeJSONKeys(nested)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func snakeToLowerCamel(value string) string {
|
|
||||||
if !strings.Contains(value, "_") {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
parts := strings.Split(value, "_")
|
|
||||||
result := parts[0]
|
|
||||||
for _, part := range parts[1:] {
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result += strings.ToUpper(part[:1]) + part[1:]
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeJSONObjects(target, patch map[string]any) {
|
|
||||||
for key, value := range patch {
|
|
||||||
incoming, isObject := value.(map[string]any)
|
|
||||||
if isObject {
|
|
||||||
if existing, ok := target[key].(map[string]any); ok {
|
|
||||||
mergeJSONObjects(existing, incoming)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
target[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func durationString(value *durationpb.Duration) string {
|
func durationString(value *durationpb.Duration) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return "0s"
|
return "0s"
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package initialize
|
||||||
|
|
||||||
|
import "github.com/google/wire"
|
||||||
|
|
||||||
|
var ProviderSet = wire.NewSet(NewRepo)
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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("支付回调签名校验失败")
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
)
|
||||||
|
|
@ -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),
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/",
|
||||||
} {
|
} {
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
package configutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MergeProtoJSON applies a JSON object as a partial protobuf update while
|
||||||
|
// retaining fields omitted by the caller.
|
||||||
|
func MergeProtoJSON(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error {
|
||||||
|
currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var current map[string]any
|
||||||
|
if err = json.Unmarshal(currentRaw, ¤t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var incoming map[string]any
|
||||||
|
if err = json.Unmarshal(patch, &incoming); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
incoming = normalizeJSONKeys(incoming).(map[string]any)
|
||||||
|
mergeJSONObjects(current, incoming)
|
||||||
|
merged, err := json.Marshal(current)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return options.Unmarshal(merged, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeJSONKeys(value any) any {
|
||||||
|
switch item := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
result := make(map[string]any, len(item))
|
||||||
|
for key, nested := range item {
|
||||||
|
result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case []any:
|
||||||
|
result := make([]any, len(item))
|
||||||
|
for index, nested := range item {
|
||||||
|
result[index] = normalizeJSONKeys(nested)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snakeToLowerCamel(value string) string {
|
||||||
|
if !strings.Contains(value, "_") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, "_")
|
||||||
|
result := parts[0]
|
||||||
|
for _, part := range parts[1:] {
|
||||||
|
if part != "" {
|
||||||
|
result += strings.ToUpper(part[:1]) + part[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeJSONObjects(target, patch map[string]any) {
|
||||||
|
for key, value := range patch {
|
||||||
|
if incoming, ok := value.(map[string]any); ok {
|
||||||
|
if existing, exists := target[key].(map[string]any); exists {
|
||||||
|
mergeJSONObjects(existing, incoming)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package paymentkit
|
package paymentutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package paymentkit
|
package paymentutil
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package paymentkit
|
package paymentutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package paymentkit
|
package paymentutil
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package paymentkit
|
package paymentutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package paymentkit
|
package paymentutil
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package paymentkit
|
package paymentutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package paymentkit
|
package paymentutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
Loading…
Reference in New Issue