优化结构
This commit is contained in:
+28
-3
@@ -15,7 +15,11 @@ feature can be found by its domain instead of by scanning one large package.
|
||||
persistence. Provider SDK implementations are separate in
|
||||
`internal/integration/payment`.
|
||||
- `internal/data/migration` contains the version-table runner used by the
|
||||
root migration coordinator.
|
||||
root migration coordinator. It stays under data because it executes and
|
||||
records database schema steps.
|
||||
- `internal/adminsurface` contains the small menu/API metadata contract used
|
||||
when a business module contributes pages to the administration UI. It lives
|
||||
outside `data` because the contract itself is not persistence code.
|
||||
|
||||
The root package intentionally keeps only cross-cutting infrastructure:
|
||||
database lifecycle/reloads, runtime configuration persistence, data-scope
|
||||
@@ -23,6 +27,27 @@ auditing, integration configuration storage, and migration orchestration.
|
||||
Repositories depend on narrow module seams (`system.Provider` and
|
||||
`payment.Provider`) rather than importing the root implementation details.
|
||||
|
||||
First-install and configuration-management orchestration lives in
|
||||
`internal/initialize`. It implements `biz.InitializationRepo` through a narrow
|
||||
backend interface that `*data.Data` satisfies. This keeps administrator/menu/API
|
||||
seeding out of the database lifecycle package while retaining one migration
|
||||
entry point.
|
||||
|
||||
Non-database adapters remain under `internal/integration` (storage, email,
|
||||
payment SDKs, and cache), while reusable GORM and pagination helpers live in
|
||||
`pkg/gormkit` and `pkg/pagination`.
|
||||
payment SDKs, and cache). Their constructors are provided by
|
||||
`internal/integration.ProviderSet`, separate from the data provider set.
|
||||
GORM and pagination helpers live beside the data layer; stateless payment
|
||||
parsing lives in `internal/utils/paymentutil`; admin JWT handling lives in
|
||||
`internal/security/adminauth`.
|
||||
|
||||
## Migration and bootstrap
|
||||
|
||||
There is one migration execution entry point: `migrateAll` in the root data
|
||||
package. The root owns the shared integration-configuration table and appends
|
||||
the steps returned by `system.Migrations()` and `payment.Migrations()`.
|
||||
|
||||
Migrations create schemas and module-required fixed records only. Initial
|
||||
business data is separate: `initialize.Repo` invokes `system.SeedSystem` only
|
||||
from the explicit first-install flow to create the administrator, roles,
|
||||
menus, APIs, departments, policies, and module-provided administration
|
||||
surfaces.
|
||||
|
||||
@@ -1,549 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
)
|
||||
|
||||
func compatibleDatabase(value *conf.Data_Database) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
path := value.Host
|
||||
if value.Driver == "sqlite" {
|
||||
path = value.Path
|
||||
}
|
||||
return map[string]any{
|
||||
"prefix": value.Prefix, "port": value.Port, "config": value.Config,
|
||||
"db-name": value.Name, "username": value.User, "password": value.Password,
|
||||
"path": path, "engine": value.Engine, "log-mode": value.LogMode,
|
||||
"max-idle-conns": value.MaxIdleConns, "max-open-conns": value.MaxOpenConns,
|
||||
"conn-max-lifetime": value.ConnMaxLifetime, "singular": value.Singular,
|
||||
}
|
||||
}
|
||||
|
||||
func compatibleRedis(value *conf.Data_Redis) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{"name": value.Name, "addr": value.Addr, "password": value.Password, "db": value.Db, "useCluster": value.UseCluster, "clusterAddrs": value.ClusterAddrs}
|
||||
}
|
||||
|
||||
func compatibleObjectStore(value *conf.AdminBackend_ObjectStore) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"endpoint": value.Endpoint, "region": value.Region, "bucket": value.Bucket,
|
||||
"access-key": value.AccessKey, "secret-key": value.SecretKey, "base-url": value.BaseUrl,
|
||||
"path-prefix": value.PathPrefix, "use-ssl": value.UseSsl,
|
||||
"s3-force-path-style": value.ForcePathStyle, "account-id": value.AccountId,
|
||||
}
|
||||
}
|
||||
|
||||
func compatibleObjectStores(storage *conf.AdminBackend_Storage) map[string]map[string]any {
|
||||
if storage == nil {
|
||||
storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
aliyun, huawei := storage.AliyunOss, storage.HuaweiObs
|
||||
tencent, aws := storage.TencentCos, storage.AwsS3
|
||||
cloudflare, minio := storage.CloudflareR2, storage.Minio
|
||||
if aliyun == nil {
|
||||
aliyun = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if huawei == nil {
|
||||
huawei = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if tencent == nil {
|
||||
tencent = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if aws == nil {
|
||||
aws = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if cloudflare == nil {
|
||||
cloudflare = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if minio == nil {
|
||||
minio = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
return map[string]map[string]any{
|
||||
"aliyun-oss": {"endpoint": aliyun.Endpoint, "access-key-id": aliyun.AccessKey, "access-key-secret": aliyun.SecretKey, "bucket-name": aliyun.Bucket, "bucket-url": aliyun.BaseUrl, "base-path": aliyun.PathPrefix},
|
||||
"hua-wei-obs": {"path": huawei.PathPrefix, "bucket": huawei.Bucket, "endpoint": huawei.Endpoint, "access-key": huawei.AccessKey, "secret-key": huawei.SecretKey},
|
||||
"tencent-cos": {"bucket": tencent.Bucket, "region": tencent.Region, "secret-id": tencent.AccessKey, "secret-key": tencent.SecretKey, "base-url": tencent.BaseUrl, "path-prefix": tencent.PathPrefix},
|
||||
"aws-s3": {"bucket": aws.Bucket, "region": aws.Region, "endpoint": aws.Endpoint, "secret-id": aws.AccessKey, "secret-key": aws.SecretKey, "base-url": aws.BaseUrl, "path-prefix": aws.PathPrefix, "s3-force-path-style": aws.ForcePathStyle, "disable-ssl": !aws.UseSsl},
|
||||
"cloudflare-r2": {"bucket": cloudflare.Bucket, "base-url": cloudflare.BaseUrl, "path": cloudflare.PathPrefix, "account-id": cloudflare.AccountId, "access-key-id": cloudflare.AccessKey, "secret-access-key": cloudflare.SecretKey},
|
||||
"minio": {"endpoint": minio.Endpoint, "access-key-id": minio.AccessKey, "access-key-secret": minio.SecretKey, "bucket-name": minio.Bucket, "use-ssl": minio.UseSsl, "base-path": minio.PathPrefix, "bucket-url": minio.BaseUrl},
|
||||
}
|
||||
}
|
||||
|
||||
func compatibleConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any {
|
||||
result := map[string]any{}
|
||||
if data == nil {
|
||||
data = &conf.Data{}
|
||||
}
|
||||
if admin == nil {
|
||||
admin = &conf.AdminBackend{}
|
||||
}
|
||||
jwt := admin.Jwt
|
||||
if jwt == nil {
|
||||
jwt = &conf.AdminBackend_JWT{}
|
||||
}
|
||||
result["jwt"] = map[string]any{"signing-key": jwt.SigningKey, "expires-time": durationString(jwt.ExpiresTime), "buffer-time": durationString(jwt.BufferTime), "issuer": jwt.Issuer}
|
||||
zap := admin.Zap
|
||||
if zap == nil {
|
||||
zap = &conf.AdminBackend_Zap{}
|
||||
}
|
||||
result["zap"] = map[string]any{
|
||||
"level": zap.Level, "prefix": zap.Prefix, "format": zap.Format, "director": zap.Director,
|
||||
"encode-level": zap.EncodeLevel, "stacktrace-key": zap.StacktraceKey, "show-line": zap.ShowLine,
|
||||
"log-in-console": zap.LogInConsole, "retention-day": zap.RetentionDay,
|
||||
"access-req-body": zap.AccessReqBody, "access-resp-data": zap.AccessRespData,
|
||||
"access-req-headers": zap.AccessReqHeaders, "access-log-max-bytes": zap.AccessLogMaxBytes,
|
||||
"file-only-modules": zap.FileOnlyModules,
|
||||
}
|
||||
result["redis"] = compatibleRedis(data.Redis)
|
||||
redisList := make([]map[string]any, 0, len(data.RedisList))
|
||||
for _, item := range data.RedisList {
|
||||
redisList = append(redisList, compatibleRedis(item))
|
||||
}
|
||||
result["redis-list"] = redisList
|
||||
mongo := data.Mongo
|
||||
if mongo == nil {
|
||||
mongo = &conf.Data_Mongo{}
|
||||
}
|
||||
hosts := make([]map[string]any, 0, len(mongo.Hosts))
|
||||
for _, host := range mongo.Hosts {
|
||||
if host != nil {
|
||||
hosts = append(hosts, map[string]any{"host": host.Host, "port": host.Port})
|
||||
}
|
||||
}
|
||||
result["mongo"] = map[string]any{"coll": mongo.Coll, "options": mongo.Options, "database": mongo.Database, "username": mongo.Username, "password": mongo.Password, "auth-source": mongo.AuthSource, "min-pool-size": mongo.MinPoolSize, "max-pool-size": mongo.MaxPoolSize, "socket-timeout-ms": mongo.SocketTimeoutMs, "connect-timeout-ms": mongo.ConnectTimeoutMs, "is-zap": mongo.IsZap, "hosts": hosts}
|
||||
email := admin.Email
|
||||
if email == nil {
|
||||
email = &conf.AdminBackend_Email{}
|
||||
}
|
||||
result["email"] = map[string]any{"to": email.To, "from": email.From, "host": email.Host, "secret": email.Secret, "nickname": email.Nickname, "port": email.Port, "is-ssl": email.IsSsl, "is-loginauth": email.IsLoginAuth}
|
||||
system := admin.System
|
||||
if system == nil {
|
||||
system = &conf.AdminBackend_System{}
|
||||
}
|
||||
storageType := "local"
|
||||
if admin.Storage != nil && admin.Storage.Type != "" {
|
||||
storageType = admin.Storage.Type
|
||||
}
|
||||
driver := ""
|
||||
if data.Database != nil {
|
||||
driver = data.Database.Driver
|
||||
}
|
||||
result["system"] = map[string]any{"db-type": driver, "oss-type": storageType, "router-prefix": admin.RouterPrefix, "addr": system.Addr, "iplimit-count": system.IplimitCount, "iplimit-time": system.IplimitTime, "use-multipoint": system.UseMultipoint, "use-redis": system.UseRedis, "use-mongo": system.UseMongo, "use-strict-auth": system.UseStrictAuth, "disable-auto-migrate": system.DisableAutoMigrate}
|
||||
for _, name := range []string{"mysql", "mssql", "pgsql", "oracle", "sqlite"} {
|
||||
result[name] = map[string]any{}
|
||||
}
|
||||
if driver != "" {
|
||||
result[driver] = compatibleDatabase(data.Database)
|
||||
}
|
||||
dbList := make([]map[string]any, 0, len(data.DatabaseList))
|
||||
for _, item := range data.DatabaseList {
|
||||
row := compatibleDatabase(item)
|
||||
if item != nil {
|
||||
row["type"], row["alias-name"], row["disable"] = item.Driver, item.AliasName, item.Disable
|
||||
}
|
||||
dbList = append(dbList, row)
|
||||
}
|
||||
result["db-list"] = dbList
|
||||
local := admin.Local
|
||||
if local == nil {
|
||||
local = &conf.AdminBackend_Local{}
|
||||
}
|
||||
result["local"] = map[string]any{"path": local.PathPrefix, "store-path": local.StorePath}
|
||||
media := admin.Media
|
||||
if media == nil {
|
||||
media = &conf.AdminBackend_Media{}
|
||||
}
|
||||
chunkDir := media.ChunkDir
|
||||
if chunkDir == "" {
|
||||
chunkDir = "uploads/chunks"
|
||||
}
|
||||
result["media"] = map[string]any{"session-ttl": media.SessionTtl, "max-file-size": media.MaxFileSize, "chunk-dir": chunkDir}
|
||||
if admin.Storage == nil {
|
||||
admin.Storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
qiniu := admin.Storage.Qiniu
|
||||
if qiniu == nil {
|
||||
qiniu = &conf.AdminBackend_Qiniu{}
|
||||
}
|
||||
result["qiniu"] = map[string]any{"zone": qiniu.Zone, "bucket": qiniu.Bucket, "img-path": qiniu.BaseUrl, "access-key": qiniu.AccessKey, "secret-key": qiniu.SecretKey, "use-https": qiniu.UseHttps, "use-cdn-domains": qiniu.UseCdnDomains}
|
||||
for key, value := range compatibleObjectStores(admin.Storage) {
|
||||
result[key] = value
|
||||
}
|
||||
disks := make([]map[string]any, 0, len(admin.DiskList))
|
||||
for _, disk := range admin.DiskList {
|
||||
if disk != nil {
|
||||
disks = append(disks, map[string]any{"mount-point": disk.MountPoint})
|
||||
}
|
||||
}
|
||||
result["disk-list"] = disks
|
||||
cors := admin.Cors
|
||||
if cors == nil {
|
||||
cors = &conf.AdminBackend_CORS{}
|
||||
}
|
||||
whitelist := make([]map[string]any, 0, len(cors.Whitelist))
|
||||
for _, rule := range cors.Whitelist {
|
||||
if rule != nil {
|
||||
whitelist = append(whitelist, map[string]any{"allow-origin": rule.AllowOrigin, "allow-methods": rule.AllowMethods, "allow-headers": rule.AllowHeaders, "expose-headers": rule.ExposeHeaders, "allow-credentials": rule.AllowCredentials})
|
||||
}
|
||||
}
|
||||
result["cors"] = map[string]any{"mode": cors.Mode, "whitelist": whitelist}
|
||||
result["app"] = map[string]any{"node": admin.GetApp().GetNode(), "app-id": admin.GetApp().GetAppId(), "env": admin.GetApp().GetEnv()}
|
||||
return result
|
||||
}
|
||||
|
||||
func objectMap(value any) map[string]any {
|
||||
result, _ := value.(map[string]any)
|
||||
return result
|
||||
}
|
||||
|
||||
func stringValue(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func boolValue(values map[string]any, key string, fallback bool) bool {
|
||||
value, ok := values[key].(bool)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func int64Value(values map[string]any, key string, fallback int64) int64 {
|
||||
value, ok := values[key].(float64)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return int64(value)
|
||||
}
|
||||
|
||||
func stringSliceValue(values map[string]any, key string, fallback []string) []string {
|
||||
items, ok := values[key].([]any)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if value, ok := item.(string); ok {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyCompatibleRedis(target *conf.Data_Redis, values map[string]any) {
|
||||
if target == nil || values == nil {
|
||||
return
|
||||
}
|
||||
target.Name, target.Addr = stringValue(values, "name"), stringValue(values, "addr")
|
||||
if password := stringValue(values, "password"); password != "" && password != "******" {
|
||||
target.Password = password
|
||||
}
|
||||
target.Db = int32(int64Value(values, "db", int64(target.Db)))
|
||||
target.UseCluster = boolValue(values, "useCluster", target.UseCluster)
|
||||
target.ClusterAddrs = stringSliceValue(values, "clusterAddrs", target.ClusterAddrs)
|
||||
}
|
||||
|
||||
func durationValue(raw string, fallback *durationpb.Duration) *durationpb.Duration {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := parseCompatibleDuration(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return durationpb.New(value)
|
||||
}
|
||||
|
||||
func parseCompatibleDuration(raw string) (time.Duration, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if value, err := time.ParseDuration(raw); err == nil {
|
||||
return value, nil
|
||||
}
|
||||
if index := strings.Index(raw, "d"); index >= 0 {
|
||||
days, _ := strconv.Atoi(raw[:index])
|
||||
value := time.Duration(days) * 24 * time.Hour
|
||||
remainder, err := time.ParseDuration(raw[index+1:])
|
||||
if err != nil {
|
||||
return value, nil
|
||||
}
|
||||
return value + remainder, nil
|
||||
}
|
||||
nanoseconds, err := strconv.ParseInt(raw, 10, 64)
|
||||
return time.Duration(nanoseconds), err
|
||||
}
|
||||
|
||||
func applyCompatibleDatabase(target *conf.Data_Database, values map[string]any, driver string) {
|
||||
if target == nil || values == nil {
|
||||
return
|
||||
}
|
||||
target.Driver = driver
|
||||
target.Prefix, target.Port, target.Config = stringValue(values, "prefix"), stringValue(values, "port"), stringValue(values, "config")
|
||||
target.Name, target.User = stringValue(values, "db-name"), stringValue(values, "username")
|
||||
if password := stringValue(values, "password"); password != "" && password != "******" {
|
||||
target.Password = password
|
||||
}
|
||||
if driver == "sqlite" {
|
||||
target.Path = stringValue(values, "path")
|
||||
} else {
|
||||
target.Host = stringValue(values, "path")
|
||||
}
|
||||
target.Engine, target.LogMode = stringValue(values, "engine"), stringValue(values, "log-mode")
|
||||
target.MaxIdleConns = int32(int64Value(values, "max-idle-conns", int64(target.MaxIdleConns)))
|
||||
target.MaxOpenConns = int32(int64Value(values, "max-open-conns", int64(target.MaxOpenConns)))
|
||||
target.ConnMaxLifetime = int32(int64Value(values, "conn-max-lifetime", int64(target.ConnMaxLifetime)))
|
||||
target.Singular = boolValue(values, "singular", target.Singular)
|
||||
}
|
||||
|
||||
func applyCompatibleObjectStore(target *conf.AdminBackend_ObjectStore, values map[string]any) {
|
||||
if target == nil || values == nil {
|
||||
return
|
||||
}
|
||||
target.Endpoint, target.Region, target.Bucket = stringValue(values, "endpoint"), stringValue(values, "region"), stringValue(values, "bucket")
|
||||
if target.Bucket == "" {
|
||||
target.Bucket = stringValue(values, "bucket-name")
|
||||
}
|
||||
target.AccessKey = stringValue(values, "access-key")
|
||||
if target.AccessKey == "" {
|
||||
target.AccessKey = stringValue(values, "access-key-id")
|
||||
}
|
||||
if target.AccessKey == "" {
|
||||
target.AccessKey = stringValue(values, "secret-id")
|
||||
}
|
||||
secret := stringValue(values, "secret-key")
|
||||
if secret == "" {
|
||||
secret = stringValue(values, "access-key-secret")
|
||||
}
|
||||
if secret == "" {
|
||||
secret = stringValue(values, "secret-access-key")
|
||||
}
|
||||
if secret != "" && secret != "******" {
|
||||
target.SecretKey = secret
|
||||
}
|
||||
target.BaseUrl = stringValue(values, "base-url")
|
||||
if target.BaseUrl == "" {
|
||||
target.BaseUrl = stringValue(values, "bucket-url")
|
||||
}
|
||||
target.PathPrefix = stringValue(values, "path-prefix")
|
||||
if target.PathPrefix == "" {
|
||||
target.PathPrefix = stringValue(values, "base-path")
|
||||
}
|
||||
if target.PathPrefix == "" {
|
||||
target.PathPrefix = stringValue(values, "path")
|
||||
}
|
||||
target.UseSsl = boolValue(values, "use-ssl", !boolValue(values, "disable-ssl", !target.UseSsl))
|
||||
target.ForcePathStyle = boolValue(values, "s3-force-path-style", target.ForcePathStyle)
|
||||
target.AccountId = stringValue(values, "account-id")
|
||||
}
|
||||
|
||||
func applyCompatibleConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.AdminBackend) error {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, compatibleShape := values["system"]; !compatibleShape {
|
||||
return nil
|
||||
}
|
||||
if data.Database == nil {
|
||||
data.Database = &conf.Data_Database{}
|
||||
}
|
||||
if admin.System == nil {
|
||||
admin.System = &conf.AdminBackend_System{}
|
||||
}
|
||||
if admin.Storage == nil {
|
||||
admin.Storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
system := objectMap(values["system"])
|
||||
driver := stringValue(system, "db-type")
|
||||
if driver == "" {
|
||||
driver = data.Database.Driver
|
||||
}
|
||||
applyCompatibleDatabase(data.Database, objectMap(values[driver]), driver)
|
||||
admin.Storage.Type = stringValue(system, "oss-type")
|
||||
admin.RouterPrefix = stringValue(system, "router-prefix")
|
||||
admin.System.UseMultipoint = boolValue(system, "use-multipoint", admin.System.UseMultipoint)
|
||||
admin.System.UseRedis = boolValue(system, "use-redis", admin.System.UseRedis)
|
||||
admin.System.UseMongo = boolValue(system, "use-mongo", admin.System.UseMongo)
|
||||
admin.System.UseStrictAuth = boolValue(system, "use-strict-auth", admin.System.UseStrictAuth)
|
||||
admin.System.DisableAutoMigrate = boolValue(system, "disable-auto-migrate", admin.System.DisableAutoMigrate)
|
||||
admin.System.Addr = int32(int64Value(system, "addr", int64(admin.System.Addr)))
|
||||
admin.System.IplimitCount = int32(int64Value(system, "iplimit-count", int64(admin.System.IplimitCount)))
|
||||
admin.System.IplimitTime = int32(int64Value(system, "iplimit-time", int64(admin.System.IplimitTime)))
|
||||
if admin.Jwt == nil {
|
||||
admin.Jwt = &conf.AdminBackend_JWT{}
|
||||
}
|
||||
jwt := objectMap(values["jwt"])
|
||||
if secret := stringValue(jwt, "signing-key"); secret != "" && secret != "******" {
|
||||
admin.Jwt.SigningKey = secret
|
||||
}
|
||||
admin.Jwt.ExpiresTime = durationValue(stringValue(jwt, "expires-time"), admin.Jwt.ExpiresTime)
|
||||
admin.Jwt.BufferTime = durationValue(stringValue(jwt, "buffer-time"), admin.Jwt.BufferTime)
|
||||
admin.Jwt.Issuer = stringValue(jwt, "issuer")
|
||||
if admin.Zap == nil {
|
||||
admin.Zap = &conf.AdminBackend_Zap{}
|
||||
}
|
||||
zap := objectMap(values["zap"])
|
||||
admin.Zap.Level, admin.Zap.Prefix, admin.Zap.Format, admin.Zap.Director = stringValue(zap, "level"), stringValue(zap, "prefix"), stringValue(zap, "format"), stringValue(zap, "director")
|
||||
admin.Zap.EncodeLevel, admin.Zap.StacktraceKey = stringValue(zap, "encode-level"), stringValue(zap, "stacktrace-key")
|
||||
admin.Zap.ShowLine, admin.Zap.LogInConsole = boolValue(zap, "show-line", admin.Zap.ShowLine), boolValue(zap, "log-in-console", admin.Zap.LogInConsole)
|
||||
admin.Zap.RetentionDay = int32(int64Value(zap, "retention-day", int64(admin.Zap.RetentionDay)))
|
||||
admin.Zap.AccessReqBody = boolValue(zap, "access-req-body", admin.Zap.AccessReqBody)
|
||||
admin.Zap.AccessRespData = boolValue(zap, "access-resp-data", admin.Zap.AccessRespData)
|
||||
admin.Zap.AccessReqHeaders = boolValue(zap, "access-req-headers", admin.Zap.AccessReqHeaders)
|
||||
admin.Zap.AccessLogMaxBytes = int32(int64Value(zap, "access-log-max-bytes", int64(admin.Zap.AccessLogMaxBytes)))
|
||||
if list, ok := zap["file-only-modules"].([]any); ok {
|
||||
admin.Zap.FileOnlyModules = admin.Zap.FileOnlyModules[:0]
|
||||
for _, item := range list {
|
||||
if value, ok := item.(string); ok {
|
||||
admin.Zap.FileOnlyModules = append(admin.Zap.FileOnlyModules, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.Redis == nil {
|
||||
data.Redis = &conf.Data_Redis{}
|
||||
}
|
||||
applyCompatibleRedis(data.Redis, objectMap(values["redis"]))
|
||||
if items, ok := values["redis-list"].([]any); ok {
|
||||
current := make(map[string]*conf.Data_Redis, len(data.RedisList))
|
||||
for _, item := range data.RedisList {
|
||||
if item != nil {
|
||||
current[item.Name] = item
|
||||
}
|
||||
}
|
||||
data.RedisList = make([]*conf.Data_Redis, 0, len(items))
|
||||
for _, item := range items {
|
||||
values := objectMap(item)
|
||||
target := current[stringValue(values, "name")]
|
||||
if target == nil {
|
||||
target = &conf.Data_Redis{}
|
||||
}
|
||||
applyCompatibleRedis(target, values)
|
||||
data.RedisList = append(data.RedisList, target)
|
||||
}
|
||||
}
|
||||
if mongo := objectMap(values["mongo"]); mongo != nil {
|
||||
if data.Mongo == nil {
|
||||
data.Mongo = &conf.Data_Mongo{}
|
||||
}
|
||||
data.Mongo.Coll, data.Mongo.Options, data.Mongo.Database = stringValue(mongo, "coll"), stringValue(mongo, "options"), stringValue(mongo, "database")
|
||||
data.Mongo.Username, data.Mongo.AuthSource = stringValue(mongo, "username"), stringValue(mongo, "auth-source")
|
||||
if password := stringValue(mongo, "password"); password != "" && password != "******" {
|
||||
data.Mongo.Password = password
|
||||
}
|
||||
data.Mongo.MinPoolSize = uint64(int64Value(mongo, "min-pool-size", int64(data.Mongo.MinPoolSize)))
|
||||
data.Mongo.MaxPoolSize = uint64(int64Value(mongo, "max-pool-size", int64(data.Mongo.MaxPoolSize)))
|
||||
data.Mongo.SocketTimeoutMs = int64Value(mongo, "socket-timeout-ms", data.Mongo.SocketTimeoutMs)
|
||||
data.Mongo.ConnectTimeoutMs = int64Value(mongo, "connect-timeout-ms", data.Mongo.ConnectTimeoutMs)
|
||||
data.Mongo.IsZap = boolValue(mongo, "is-zap", data.Mongo.IsZap)
|
||||
if hosts, ok := mongo["hosts"].([]any); ok {
|
||||
data.Mongo.Hosts = make([]*conf.Data_MongoHost, 0, len(hosts))
|
||||
for _, item := range hosts {
|
||||
host := objectMap(item)
|
||||
data.Mongo.Hosts = append(data.Mongo.Hosts, &conf.Data_MongoHost{Host: stringValue(host, "host"), Port: stringValue(host, "port")})
|
||||
}
|
||||
}
|
||||
}
|
||||
if items, ok := values["db-list"].([]any); ok {
|
||||
current := make(map[string]*conf.Data_Database, len(data.DatabaseList))
|
||||
for _, item := range data.DatabaseList {
|
||||
if item != nil {
|
||||
current[item.AliasName] = item
|
||||
}
|
||||
}
|
||||
data.DatabaseList = make([]*conf.Data_Database, 0, len(items))
|
||||
for _, item := range items {
|
||||
row := objectMap(item)
|
||||
alias := stringValue(row, "alias-name")
|
||||
target := current[alias]
|
||||
if target == nil {
|
||||
target = &conf.Data_Database{}
|
||||
}
|
||||
applyCompatibleDatabase(target, row, stringValue(row, "type"))
|
||||
target.AliasName, target.Disable = alias, boolValue(row, "disable", target.Disable)
|
||||
data.DatabaseList = append(data.DatabaseList, target)
|
||||
}
|
||||
}
|
||||
if admin.Email == nil {
|
||||
admin.Email = &conf.AdminBackend_Email{}
|
||||
}
|
||||
email := objectMap(values["email"])
|
||||
admin.Email.To, admin.Email.From, admin.Email.Host = stringValue(email, "to"), stringValue(email, "from"), stringValue(email, "host")
|
||||
admin.Email.Nickname = stringValue(email, "nickname")
|
||||
admin.Email.Port = int32(int64Value(email, "port", int64(admin.Email.Port)))
|
||||
admin.Email.IsSsl, admin.Email.IsLoginAuth = boolValue(email, "is-ssl", admin.Email.IsSsl), boolValue(email, "is-loginauth", admin.Email.IsLoginAuth)
|
||||
if secret := stringValue(email, "secret"); secret != "" && secret != "******" {
|
||||
admin.Email.Secret = secret
|
||||
}
|
||||
if admin.Local == nil {
|
||||
admin.Local = &conf.AdminBackend_Local{}
|
||||
}
|
||||
local := objectMap(values["local"])
|
||||
admin.Local.PathPrefix, admin.Local.StorePath = stringValue(local, "path"), stringValue(local, "store-path")
|
||||
if admin.Media == nil {
|
||||
admin.Media = &conf.AdminBackend_Media{}
|
||||
}
|
||||
media := objectMap(values["media"])
|
||||
admin.Media.SessionTtl = int32(int64Value(media, "session-ttl", int64(admin.Media.SessionTtl)))
|
||||
admin.Media.MaxFileSize = int64Value(media, "max-file-size", admin.Media.MaxFileSize)
|
||||
admin.Media.ChunkDir = stringValue(media, "chunk-dir")
|
||||
if admin.Storage.Qiniu == nil {
|
||||
admin.Storage.Qiniu = &conf.AdminBackend_Qiniu{}
|
||||
}
|
||||
qiniu := objectMap(values["qiniu"])
|
||||
admin.Storage.Qiniu.Zone, admin.Storage.Qiniu.Bucket, admin.Storage.Qiniu.BaseUrl = stringValue(qiniu, "zone"), stringValue(qiniu, "bucket"), stringValue(qiniu, "img-path")
|
||||
admin.Storage.Qiniu.AccessKey = stringValue(qiniu, "access-key")
|
||||
if secret := stringValue(qiniu, "secret-key"); secret != "" && secret != "******" {
|
||||
admin.Storage.Qiniu.SecretKey = secret
|
||||
}
|
||||
admin.Storage.Qiniu.UseHttps, admin.Storage.Qiniu.UseCdnDomains = boolValue(qiniu, "use-https", admin.Storage.Qiniu.UseHttps), boolValue(qiniu, "use-cdn-domains", admin.Storage.Qiniu.UseCdnDomains)
|
||||
stores := []struct {
|
||||
key string
|
||||
target **conf.AdminBackend_ObjectStore
|
||||
}{{"aliyun-oss", &admin.Storage.AliyunOss}, {"hua-wei-obs", &admin.Storage.HuaweiObs}, {"tencent-cos", &admin.Storage.TencentCos}, {"aws-s3", &admin.Storage.AwsS3}, {"cloudflare-r2", &admin.Storage.CloudflareR2}, {"minio", &admin.Storage.Minio}}
|
||||
for _, store := range stores {
|
||||
if *store.target == nil {
|
||||
*store.target = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
applyCompatibleObjectStore(*store.target, objectMap(values[store.key]))
|
||||
}
|
||||
if disks, ok := values["disk-list"].([]any); ok {
|
||||
admin.DiskList = make([]*conf.AdminBackend_Disk, 0, len(disks))
|
||||
for _, item := range disks {
|
||||
disk := objectMap(item)
|
||||
admin.DiskList = append(admin.DiskList, &conf.AdminBackend_Disk{MountPoint: stringValue(disk, "mount-point")})
|
||||
}
|
||||
}
|
||||
if cors := objectMap(values["cors"]); cors != nil {
|
||||
if admin.Cors == nil {
|
||||
admin.Cors = &conf.AdminBackend_CORS{}
|
||||
}
|
||||
admin.Cors.Mode = stringValue(cors, "mode")
|
||||
if rules, ok := cors["whitelist"].([]any); ok {
|
||||
admin.Cors.Whitelist = make([]*conf.AdminBackend_CORSRule, 0, len(rules))
|
||||
for _, item := range rules {
|
||||
rule := objectMap(item)
|
||||
admin.Cors.Whitelist = append(admin.Cors.Whitelist, &conf.AdminBackend_CORSRule{AllowOrigin: stringValue(rule, "allow-origin"), AllowMethods: stringValue(rule, "allow-methods"), AllowHeaders: stringValue(rule, "allow-headers"), ExposeHeaders: stringValue(rule, "expose-headers"), AllowCredentials: boolValue(rule, "allow-credentials", false)})
|
||||
}
|
||||
}
|
||||
}
|
||||
if app := objectMap(values["app"]); app != nil {
|
||||
if admin.App == nil {
|
||||
admin.App = &conf.AdminBackend_App{}
|
||||
}
|
||||
admin.App.Node, admin.App.AppId, admin.App.Env = stringValue(app, "node"), stringValue(app, "app-id"), stringValue(app, "env")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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 = "******"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
)
|
||||
|
||||
type configurationEnvelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Admin json.RawMessage `json:"admin"`
|
||||
Email *struct {
|
||||
To string `json:"to"`
|
||||
From string `json:"from"`
|
||||
Host string `json:"host"`
|
||||
Secret string `json:"secret"`
|
||||
Nickname string `json:"nickname"`
|
||||
Port int32 `json:"port"`
|
||||
IsSSL bool `json:"is-ssl"`
|
||||
IsLoginAuth bool `json:"is-loginauth"`
|
||||
} `json:"email"`
|
||||
}
|
||||
|
||||
func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
|
||||
dataConfig, adminConfig := r.data.runtime.Values()
|
||||
admin := map[string]any{"routerPrefix": ""}
|
||||
email := map[string]any{}
|
||||
if adminConfig != nil {
|
||||
admin["routerPrefix"] = adminConfig.RouterPrefix
|
||||
if adminConfig.System != nil {
|
||||
admin["system"] = map[string]any{
|
||||
"useRedis": adminConfig.System.UseRedis,
|
||||
"useMultipoint": adminConfig.System.UseMultipoint,
|
||||
"useStrictAuth": adminConfig.System.UseStrictAuth,
|
||||
"disableAutoMigrate": adminConfig.System.DisableAutoMigrate,
|
||||
"useMongo": adminConfig.System.UseMongo,
|
||||
"addr": adminConfig.System.Addr,
|
||||
"iplimitCount": adminConfig.System.IplimitCount,
|
||||
"iplimitTime": adminConfig.System.IplimitTime,
|
||||
}
|
||||
}
|
||||
if adminConfig.Jwt != nil {
|
||||
admin["jwt"] = map[string]any{"signingKey": "******", "expiresTime": durationString(adminConfig.Jwt.ExpiresTime), "bufferTime": durationString(adminConfig.Jwt.BufferTime), "issuer": adminConfig.Jwt.Issuer}
|
||||
}
|
||||
if adminConfig.Captcha != nil {
|
||||
admin["captcha"] = map[string]any{"keyLong": adminConfig.Captcha.KeyLong, "imgWidth": adminConfig.Captcha.ImgWidth, "imgHeight": adminConfig.Captcha.ImgHeight, "storeExpiration": durationString(adminConfig.Captcha.StoreExpiration)}
|
||||
}
|
||||
if adminConfig.Local != nil {
|
||||
admin["local"] = map[string]any{"storePath": adminConfig.Local.StorePath, "pathPrefix": adminConfig.Local.PathPrefix}
|
||||
}
|
||||
if adminConfig.Media != nil {
|
||||
chunkDir := adminConfig.Media.ChunkDir
|
||||
if chunkDir == "" {
|
||||
chunkDir = "uploads/chunks"
|
||||
}
|
||||
admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize, "chunkDir": chunkDir}
|
||||
}
|
||||
if adminConfig.Email != nil {
|
||||
secret := ""
|
||||
if adminConfig.Email.Secret != "" {
|
||||
secret = "******"
|
||||
}
|
||||
email = map[string]any{"to": adminConfig.Email.To, "from": adminConfig.Email.From, "host": adminConfig.Email.Host, "secret": secret, "nickname": adminConfig.Email.Nickname, "port": adminConfig.Email.Port, "is-ssl": adminConfig.Email.IsSsl, "is-loginauth": adminConfig.Email.IsLoginAuth}
|
||||
}
|
||||
if adminConfig.Storage != nil {
|
||||
storage := proto.Clone(adminConfig.Storage).(*conf.AdminBackend_Storage)
|
||||
maskStorageSecrets(storage)
|
||||
admin["storage"] = storage
|
||||
}
|
||||
disks := make([]map[string]any, 0, len(adminConfig.DiskList))
|
||||
for _, disk := range adminConfig.DiskList {
|
||||
if disk != nil {
|
||||
disks = append(disks, map[string]any{"mount_point": disk.MountPoint})
|
||||
}
|
||||
}
|
||||
admin["disk_list"] = disks
|
||||
if adminConfig.Zap != nil {
|
||||
admin["zap"] = adminConfig.Zap
|
||||
}
|
||||
if adminConfig.Cors != nil {
|
||||
admin["cors"] = adminConfig.Cors
|
||||
}
|
||||
if adminConfig.App != nil {
|
||||
admin["app"] = adminConfig.App
|
||||
}
|
||||
}
|
||||
// Never mask secrets on the live runtime object. ConfigurationJSON is a
|
||||
// read-only operation; mutating dataConfig here would replace the actual
|
||||
// database/Redis/Mongo credentials with "******" and make the next reload
|
||||
// fail. Work on a clone, just as we already do for adminConfig.
|
||||
safeData := cloneDataConfig(dataConfig)
|
||||
maskDataSecrets(safeData)
|
||||
safeAdmin := cloneAdminConfig(adminConfig)
|
||||
if safeAdmin != nil {
|
||||
if safeAdmin.Jwt != nil {
|
||||
safeAdmin.Jwt.SigningKey = "******"
|
||||
}
|
||||
if safeAdmin.Email != nil && safeAdmin.Email.Secret != "" {
|
||||
safeAdmin.Email.Secret = "******"
|
||||
}
|
||||
maskStorageSecrets(safeAdmin.Storage)
|
||||
}
|
||||
dataMap := map[string]any{}
|
||||
if safeData != nil {
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(safeData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(raw, &dataMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
config := compatibleConfiguration(safeData, safeAdmin)
|
||||
config["admin"], config["email"], config["data"] = admin, email, dataMap
|
||||
return json.Marshal(map[string]any{"config": config})
|
||||
}
|
||||
|
||||
func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error {
|
||||
currentData, currentAdmin := r.data.runtime.Values()
|
||||
if currentAdmin == nil {
|
||||
return nil
|
||||
}
|
||||
nextData := cloneDataConfig(currentData)
|
||||
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
if err := applyCompatibleConfiguration(raw, nextData, nextAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
var value configurationEnvelope
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
options := protojson.UnmarshalOptions{DiscardUnknown: true}
|
||||
if len(value.Data) > 0 && string(value.Data) != "null" {
|
||||
if err := mergeConfigurationMessage(nextData, value.Data, options); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(value.Admin) > 0 && string(value.Admin) != "null" {
|
||||
if err := mergeConfigurationMessage(nextAdmin, value.Admin, options); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
preserveDataSecrets(nextData, currentData)
|
||||
preserveAdminSecrets(nextAdmin, currentAdmin)
|
||||
if value.Email != nil {
|
||||
if nextAdmin.Email == nil {
|
||||
nextAdmin.Email = &conf.AdminBackend_Email{}
|
||||
}
|
||||
nextAdmin.Email.To, nextAdmin.Email.From, nextAdmin.Email.Host = value.Email.To, value.Email.From, value.Email.Host
|
||||
nextAdmin.Email.Nickname, nextAdmin.Email.Port = value.Email.Nickname, value.Email.Port
|
||||
nextAdmin.Email.IsSsl, nextAdmin.Email.IsLoginAuth = value.Email.IsSSL, value.Email.IsLoginAuth
|
||||
if value.Email.Secret != "" && value.Email.Secret != "******" {
|
||||
nextAdmin.Email.Secret = value.Email.Secret
|
||||
}
|
||||
}
|
||||
if err := refreshDatabaseSources(nextData); err != nil {
|
||||
return err
|
||||
}
|
||||
nextAdmin.ConfigPath = currentAdmin.ConfigPath
|
||||
dataRaw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(nextData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminRaw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(nextAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.PersistRuntimeConfig(ctx, dataRaw, adminRaw)
|
||||
}
|
||||
|
||||
func (r *initializationRepo) DiskMountPoints() []string {
|
||||
config := r.data.runtime.Admin()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
points := make([]string, 0, len(config.DiskList))
|
||||
for _, item := range config.DiskList {
|
||||
if item != nil && item.MountPoint != "" {
|
||||
points = append(points, item.MountPoint)
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func cloneDataConfig(value *conf.Data) *conf.Data {
|
||||
if value == nil {
|
||||
return &conf.Data{}
|
||||
}
|
||||
return proto.Clone(value).(*conf.Data)
|
||||
}
|
||||
|
||||
func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend {
|
||||
if value == nil {
|
||||
return &conf.AdminBackend{}
|
||||
}
|
||||
return proto.Clone(value).(*conf.AdminBackend)
|
||||
}
|
||||
|
||||
func refreshDatabaseSources(value *conf.Data) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if err := refreshDatabaseSource(value.Database); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, database := range value.DatabaseList {
|
||||
if database == nil || database.Disable {
|
||||
continue
|
||||
}
|
||||
if err := refreshDatabaseSource(database); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func refreshDatabaseSource(database *conf.Data_Database) error {
|
||||
if database == nil {
|
||||
return nil
|
||||
}
|
||||
hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" ||
|
||||
database.Password != "" || database.Name != "" || database.Config != "" || database.Path != ""
|
||||
if !hasStructuredConfig {
|
||||
// A source-only configuration is an intentional escape hatch for custom
|
||||
// driver DSNs; do not reinterpret it as the structured form.
|
||||
return nil
|
||||
}
|
||||
previousSource := database.Source
|
||||
database.Source = ""
|
||||
source, err := databaseDSN(database, "")
|
||||
if err != nil {
|
||||
database.Source = previousSource
|
||||
return err
|
||||
}
|
||||
database.Source = source
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeConfigurationMessage applies a JSON object as a partial update while
|
||||
// retaining fields that were not sent by the caller. protojson.Unmarshal is
|
||||
// intentionally not used directly here because its reset semantics would
|
||||
// clear unrelated configuration sections when the management page submits a
|
||||
// partial object.
|
||||
func mergeConfigurationMessage(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error {
|
||||
currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var current map[string]any
|
||||
if err = json.Unmarshal(currentRaw, ¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
var incoming map[string]any
|
||||
if err = json.Unmarshal(patch, &incoming); err != nil {
|
||||
return err
|
||||
}
|
||||
incoming = normalizeJSONKeys(incoming).(map[string]any)
|
||||
mergeJSONObjects(current, incoming)
|
||||
merged, err := json.Marshal(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return options.Unmarshal(merged, target)
|
||||
}
|
||||
|
||||
func normalizeJSONKeys(value any) any {
|
||||
switch item := value.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(item))
|
||||
for key, nested := range item {
|
||||
result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(item))
|
||||
for index, nested := range item {
|
||||
result[index] = normalizeJSONKeys(nested)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func snakeToLowerCamel(value string) string {
|
||||
if !strings.Contains(value, "_") {
|
||||
return value
|
||||
}
|
||||
parts := strings.Split(value, "_")
|
||||
result := parts[0]
|
||||
for _, part := range parts[1:] {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
result += strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mergeJSONObjects(target, patch map[string]any) {
|
||||
for key, value := range patch {
|
||||
incoming, isObject := value.(map[string]any)
|
||||
if isObject {
|
||||
if existing, ok := target[key].(map[string]any); ok {
|
||||
mergeJSONObjects(existing, incoming)
|
||||
continue
|
||||
}
|
||||
}
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func durationString(value *durationpb.Duration) string {
|
||||
if value == nil {
|
||||
return "0s"
|
||||
}
|
||||
return value.AsDuration().String()
|
||||
}
|
||||
|
||||
func maskDataSecrets(value *conf.Data) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
if value.Database != nil {
|
||||
value.Database.Password = "******"
|
||||
// Source may contain an embedded password (for example a MySQL DSN).
|
||||
// The management response must never expose it. Structured database
|
||||
// fields are retained and refreshDatabaseSources rebuilds Source when
|
||||
// the configuration is saved.
|
||||
value.Database.Source = ""
|
||||
}
|
||||
if value.Redis != nil {
|
||||
value.Redis.Password = "******"
|
||||
}
|
||||
if value.Mongo != nil {
|
||||
value.Mongo.Password = "******"
|
||||
}
|
||||
for _, item := range value.DatabaseList {
|
||||
if item != nil {
|
||||
item.Password = "******"
|
||||
item.Source = ""
|
||||
}
|
||||
}
|
||||
for _, item := range value.RedisList {
|
||||
if item != nil {
|
||||
item.Password = "******"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preserveDataSecrets(next, current *conf.Data) {
|
||||
if next == nil || current == nil {
|
||||
return
|
||||
}
|
||||
if next.Database != nil && current.Database != nil && maskedSecret(next.Database.Password) {
|
||||
next.Database.Password = current.Database.Password
|
||||
}
|
||||
if next.Database != nil && current.Database != nil && next.Database.Source == "" {
|
||||
next.Database.Source = current.Database.Source
|
||||
}
|
||||
if next.Redis != nil && current.Redis != nil && maskedSecret(next.Redis.Password) {
|
||||
next.Redis.Password = current.Redis.Password
|
||||
}
|
||||
if next.Mongo != nil && current.Mongo != nil && maskedSecret(next.Mongo.Password) {
|
||||
next.Mongo.Password = current.Mongo.Password
|
||||
}
|
||||
currentDatabases := make(map[string]*conf.Data_Database, len(current.DatabaseList))
|
||||
for _, item := range current.DatabaseList {
|
||||
if item != nil && item.AliasName != "" {
|
||||
currentDatabases[item.AliasName] = item
|
||||
}
|
||||
}
|
||||
for i, item := range next.DatabaseList {
|
||||
var previous *conf.Data_Database
|
||||
if item != nil && item.AliasName != "" {
|
||||
previous = currentDatabases[item.AliasName]
|
||||
}
|
||||
if previous == nil && i < len(current.DatabaseList) {
|
||||
previous = current.DatabaseList[i]
|
||||
}
|
||||
if item != nil && previous != nil {
|
||||
if maskedSecret(item.Password) {
|
||||
item.Password = previous.Password
|
||||
}
|
||||
if item.Source == "" {
|
||||
item.Source = previous.Source
|
||||
}
|
||||
}
|
||||
}
|
||||
currentRedis := make(map[string]*conf.Data_Redis, len(current.RedisList))
|
||||
for _, item := range current.RedisList {
|
||||
if item != nil && item.Name != "" {
|
||||
currentRedis[item.Name] = item
|
||||
}
|
||||
}
|
||||
for i, item := range next.RedisList {
|
||||
var previous *conf.Data_Redis
|
||||
if item != nil && item.Name != "" {
|
||||
previous = currentRedis[item.Name]
|
||||
}
|
||||
if previous == nil && i < len(current.RedisList) {
|
||||
previous = current.RedisList[i]
|
||||
}
|
||||
if item != nil && previous != nil && maskedSecret(item.Password) {
|
||||
item.Password = previous.Password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preserveAdminSecrets(next, current *conf.AdminBackend) {
|
||||
if next.Jwt != nil && current.Jwt != nil && maskedSecret(next.Jwt.SigningKey) {
|
||||
next.Jwt.SigningKey = current.Jwt.SigningKey
|
||||
}
|
||||
if next.Email != nil && current.Email != nil && maskedSecret(next.Email.Secret) {
|
||||
next.Email.Secret = current.Email.Secret
|
||||
}
|
||||
preserveStorageSecrets(next.Storage, current.Storage)
|
||||
}
|
||||
|
||||
func maskedSecret(value string) bool { return value == "" || value == "******" }
|
||||
|
||||
func objectStores(storage *conf.AdminBackend_Storage) []*conf.AdminBackend_ObjectStore {
|
||||
if storage == nil {
|
||||
return nil
|
||||
}
|
||||
return []*conf.AdminBackend_ObjectStore{storage.AliyunOss, storage.HuaweiObs, storage.TencentCos, storage.AwsS3, storage.CloudflareR2, storage.Minio}
|
||||
}
|
||||
|
||||
func maskStorageSecrets(storage *conf.AdminBackend_Storage) {
|
||||
if storage == nil {
|
||||
return
|
||||
}
|
||||
if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" {
|
||||
storage.Qiniu.SecretKey = "******"
|
||||
}
|
||||
for _, item := range objectStores(storage) {
|
||||
if item != nil && item.SecretKey != "" {
|
||||
item.SecretKey = "******"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preserveStorageSecrets(next, current *conf.AdminBackend_Storage) {
|
||||
if next == nil || current == nil {
|
||||
return
|
||||
}
|
||||
if next.Qiniu != nil && current.Qiniu != nil && maskedSecret(next.Qiniu.SecretKey) {
|
||||
next.Qiniu.SecretKey = current.Qiniu.SecretKey
|
||||
}
|
||||
nextItems, currentItems := objectStores(next), objectStores(current)
|
||||
for i := range nextItems {
|
||||
if nextItems[i] != nil && currentItems[i] != nil && maskedSecret(nextItems[i].SecretKey) {
|
||||
nextItems[i].SecretKey = currentItems[i].SecretKey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,9 @@ import (
|
||||
"github.com/google/wire"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
datapayment "kra/internal/data/payment"
|
||||
datasystem "kra/internal/data/system"
|
||||
"kra/internal/integration/cache"
|
||||
"kra/internal/integration/email"
|
||||
"kra/internal/integration/storage"
|
||||
)
|
||||
|
||||
@@ -25,15 +22,13 @@ var ProviderSet = wire.NewSet(
|
||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||
wire.Bind(new(datapayment.Provider), new(*Data)),
|
||||
wire.Bind(new(cache.RedisProvider), new(*Data)),
|
||||
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),
|
||||
datasystem.NewRuntimeSettings,
|
||||
datasystem.NewTokenIssuer,
|
||||
datasystem.NewUserRepo, NewInitializationRepo, datasystem.NewAuthorityAccessRepo, datasystem.NewAPIRepo, datasystem.NewPermissionRepo,
|
||||
datasystem.NewUserRepo, datasystem.NewAuthorityAccessRepo, datasystem.NewAPIRepo, datasystem.NewPermissionRepo,
|
||||
datasystem.NewMenuRepo, datasystem.NewDepartmentRepo, datasystem.NewPositionRepo, datasystem.NewDictionaryRepo, datasystem.NewParameterRepo, datasystem.NewAPITokenRepo,
|
||||
datasystem.NewSecurityRepo,
|
||||
datasystem.NewVersionRepo, datasystem.NewExportRepo, datasystem.NewAuditRepo, datasystem.NewAuditRecorderRepo, datasystem.NewLogFileRepo, datasystem.NewTaskRepo,
|
||||
datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, email.NewEmailRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo, cache.New, storage.NewFileStorage,
|
||||
datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo,
|
||||
)
|
||||
|
||||
type Data struct {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
"kra/internal/conf"
|
||||
"kra/pkg/gormkit"
|
||||
"kra/internal/data/gormkit"
|
||||
)
|
||||
|
||||
var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/pkg/gormkit"
|
||||
"kra/pkg/logging"
|
||||
"kra/internal/data/gormkit"
|
||||
"kra/internal/logging"
|
||||
|
||||
"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.
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package gormkit contains reusable GORM integration helpers.
|
||||
package gormkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// JSON is a portable JSON value for GORM models. It selects a native JSON
|
||||
// column where the driver supports one and a textual fallback elsewhere.
|
||||
type JSON []byte
|
||||
|
||||
func (JSON) GormDataType() string { return "json" }
|
||||
func (JSON) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
|
||||
switch db.Dialector.Name() {
|
||||
case "mysql", "sqlite":
|
||||
return "JSON"
|
||||
case "postgres":
|
||||
return "JSONB"
|
||||
case "sqlserver":
|
||||
return "NVARCHAR(MAX)"
|
||||
case "oracle":
|
||||
return "CLOB"
|
||||
default:
|
||||
return "TEXT"
|
||||
}
|
||||
}
|
||||
func (j JSON) Value() (driver.Value, error) {
|
||||
if len(j) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return string(j), nil
|
||||
}
|
||||
func (j *JSON) Scan(value any) error {
|
||||
switch raw := value.(type) {
|
||||
case nil:
|
||||
*j = nil
|
||||
case []byte:
|
||||
*j = append((*j)[:0], raw...)
|
||||
case string:
|
||||
*j = append((*j)[:0], raw...)
|
||||
default:
|
||||
return fmt.Errorf("cannot scan JSON from %T", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logger forwards GORM diagnostics through an application slog logger.
|
||||
type Logger struct {
|
||||
logger *slog.Logger
|
||||
slowThreshold time.Duration
|
||||
level logger.LogLevel
|
||||
}
|
||||
|
||||
func NewLogger(log *slog.Logger, level logger.LogLevel) *Logger {
|
||||
if log == nil {
|
||||
log = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
return &Logger{logger: log, slowThreshold: 200 * time.Millisecond, level: level}
|
||||
}
|
||||
|
||||
func (g *Logger) LogMode(level logger.LogLevel) logger.Interface {
|
||||
next := *g
|
||||
next.level = level
|
||||
return &next
|
||||
}
|
||||
|
||||
func (g *Logger) Info(ctx context.Context, message string, args ...any) {
|
||||
g.logger.InfoContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
|
||||
}
|
||||
|
||||
func (g *Logger) Warn(ctx context.Context, message string, args ...any) {
|
||||
g.logger.WarnContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
|
||||
}
|
||||
|
||||
func (g *Logger) Error(ctx context.Context, message string, args ...any) {
|
||||
g.logger.ErrorContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
|
||||
}
|
||||
|
||||
func (g *Logger) Trace(ctx context.Context, begin time.Time, query func() (string, int64), queryErr error) {
|
||||
if g.level <= logger.Silent {
|
||||
return
|
||||
}
|
||||
elapsed := time.Since(begin)
|
||||
sql, rows := query()
|
||||
fields := []any{"mod", "sql", "gorm_logger", true, "sql", sql, "rows", rows, "elapsed_ms", elapsed.Milliseconds()}
|
||||
switch {
|
||||
case queryErr != nil && g.level >= logger.Error && !errors.Is(queryErr, logger.ErrRecordNotFound):
|
||||
fields = append(fields, "error", queryErr)
|
||||
g.logger.ErrorContext(ctx, "SQL 执行错误", fields...)
|
||||
case elapsed > g.slowThreshold && g.level >= logger.Warn:
|
||||
g.logger.WarnContext(ctx, "SQL 慢查询", fields...)
|
||||
case g.level >= logger.Info:
|
||||
g.logger.InfoContext(ctx, "SQL", fields...)
|
||||
}
|
||||
}
|
||||
@@ -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/conf"
|
||||
datasystem "kra/internal/data/system"
|
||||
"kra/internal/integration/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (r *initializationRepo) PersistConfig(context.Context) error { return r.data.persistConfig() }
|
||||
func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte) error {
|
||||
currentData, currentAdmin := r.data.runtime.Values()
|
||||
func (d *Data) RuntimeValues() (*conf.Data, *conf.AdminBackend) { return d.runtime.Values() }
|
||||
func (d *Data) RuntimeAdmin() *conf.AdminBackend { return d.runtime.Admin() }
|
||||
func (d *Data) RefreshDatabaseSources(value *conf.Data) error { return refreshDatabaseSources(value) }
|
||||
|
||||
func refreshDatabaseSources(value *conf.Data) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if err := refreshDatabaseSource(value.Database); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, database := range value.DatabaseList {
|
||||
if database == nil || database.Disable {
|
||||
continue
|
||||
}
|
||||
if err := refreshDatabaseSource(database); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func refreshDatabaseSource(database *conf.Data_Database) error {
|
||||
if database == nil {
|
||||
return nil
|
||||
}
|
||||
hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || database.Password != "" || database.Name != "" || database.Config != "" || database.Path != ""
|
||||
if !hasStructuredConfig {
|
||||
return nil
|
||||
}
|
||||
previousSource := database.Source
|
||||
database.Source = ""
|
||||
source, err := databaseDSN(database, "")
|
||||
if err != nil {
|
||||
database.Source = previousSource
|
||||
return err
|
||||
}
|
||||
database.Source = source
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Data) PersistConfig(context.Context) error { return d.persistConfig() }
|
||||
func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error {
|
||||
currentData, currentAdmin := d.runtime.Values()
|
||||
next := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil {
|
||||
return err
|
||||
@@ -33,25 +74,25 @@ func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistStorageIntegrationConfig(ctx, next.Storage); err != nil {
|
||||
if err := d.persistStorageIntegrationConfig(ctx, next.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
|
||||
if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistConfigValues(currentData, next); err != nil {
|
||||
if err := d.persistConfigValues(currentData, next); err != nil {
|
||||
return err
|
||||
}
|
||||
// Writing through the management API updates the same in-memory values
|
||||
// immediately; the file watcher remains the fallback for external edits.
|
||||
r.data.runtime.Replace(currentData, next)
|
||||
if r.data.storage != nil {
|
||||
r.data.storage.Replace(candidateStorage)
|
||||
d.runtime.Replace(currentData, next)
|
||||
if d.storage != nil {
|
||||
d.storage.Replace(candidateStorage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error {
|
||||
currentData, currentAdmin := r.data.runtime.Values()
|
||||
func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error {
|
||||
currentData, currentAdmin := d.runtime.Values()
|
||||
nextData := proto.Clone(currentData).(*conf.Data)
|
||||
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
options := protojson.UnmarshalOptions{DiscardUnknown: true}
|
||||
@@ -72,32 +113,35 @@ func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil {
|
||||
if err := d.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
|
||||
if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistConfigValues(nextData, nextAdmin); err != nil {
|
||||
if err := d.persistConfigValues(nextData, nextAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
r.data.runtime.Replace(nextData, nextAdmin)
|
||||
if r.data.storage != nil {
|
||||
r.data.storage.Replace(candidateStorage)
|
||||
d.runtime.Replace(nextData, nextAdmin)
|
||||
if d.storage != nil {
|
||||
d.storage.Replace(candidateStorage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *initializationRepo) ReloadConfig(ctx context.Context) error {
|
||||
return r.data.reloadConfig(ctx)
|
||||
func (d *Data) ReloadConfig(ctx context.Context) error {
|
||||
return d.reloadConfig(ctx)
|
||||
}
|
||||
|
||||
func (r *initializationRepo) IsInitialized(ctx context.Context) (bool, error) {
|
||||
return r.data.databaseReady.Load(), nil
|
||||
func (d *Data) IsInitialized(context.Context) (bool, error) {
|
||||
return d.databaseReady.Load(), nil
|
||||
}
|
||||
|
||||
func (r *initializationRepo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error {
|
||||
// InitializeDatabase opens and activates the configured database. The callback
|
||||
// is the application-level first-install hook; data owns only lifecycle and
|
||||
// schema migration, while initialize owns system seed orchestration.
|
||||
func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig, seed func(context.Context, *gorm.DB) error) error {
|
||||
config := &conf.Data_Database{}
|
||||
if current := r.data.runtime.Data(); current != nil && current.Database != nil {
|
||||
if current := d.runtime.Data(); current != nil && current.Database != nil {
|
||||
config = proto.Clone(current.Database).(*conf.Data_Database)
|
||||
}
|
||||
config.Driver = input.Driver
|
||||
@@ -114,16 +158,16 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||
return err
|
||||
}
|
||||
config.Source = source
|
||||
r.data.initMu.Lock()
|
||||
defer r.data.initMu.Unlock()
|
||||
initialized, err := r.IsInitialized(ctx)
|
||||
d.initMu.Lock()
|
||||
defer d.initMu.Unlock()
|
||||
initialized, err := d.IsInitialized(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if initialized {
|
||||
return errors.New("数据库已初始化,无需重复初始化")
|
||||
}
|
||||
candidate, err := openDatabase(config, true, input.Template, r.data.logger())
|
||||
candidate, err := openDatabase(config, true, input.Template, d.logger())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -139,10 +183,12 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||
if err := migrateAll(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := datasystem.SeedSystem(ctx, db, input); err != nil {
|
||||
return err
|
||||
if seed != nil {
|
||||
if err := seed(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
currentAdmin := r.data.runtime.Admin()
|
||||
currentAdmin := d.runtime.Admin()
|
||||
var legacyStorage *conf.AdminBackend_Storage
|
||||
if currentAdmin != nil {
|
||||
legacyStorage = currentAdmin.Storage
|
||||
@@ -160,11 +206,11 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||
return fmt.Errorf("initialize email integration configuration: %w", err)
|
||||
}
|
||||
signingKey := uuid.NewString()
|
||||
if err := r.data.persistDatabaseConfig(config, signingKey); err != nil {
|
||||
if err := d.persistDatabaseConfig(config, signingKey); err != nil {
|
||||
return fmt.Errorf("persist database configuration: %w", err)
|
||||
}
|
||||
r.data.activateDatabase(candidate, config)
|
||||
currentData, currentAdmin := r.data.runtime.Values()
|
||||
d.activateDatabase(candidate, config)
|
||||
currentData, currentAdmin := d.runtime.Values()
|
||||
if currentAdmin == nil {
|
||||
currentAdmin = &conf.AdminBackend{}
|
||||
}
|
||||
@@ -174,7 +220,7 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||
currentAdmin.Jwt.SigningKey = signingKey
|
||||
currentAdmin.Storage = storageConfig
|
||||
currentAdmin.Email = emailConfig
|
||||
r.data.runtime.Replace(currentData, currentAdmin)
|
||||
d.runtime.Replace(currentData, currentAdmin)
|
||||
activated = true
|
||||
return nil
|
||||
}
|
||||
+2
-3
@@ -35,9 +35,8 @@ func TestDatabaseConnectionConfigured(t *testing.T) {
|
||||
|
||||
func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
|
||||
data := &Data{}
|
||||
repo := &initializationRepo{data: data}
|
||||
|
||||
initialized, err := repo.IsInitialized(context.Background())
|
||||
initialized, err := data.IsInitialized(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInitialized() error = %v", err)
|
||||
}
|
||||
@@ -46,7 +45,7 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
|
||||
}
|
||||
|
||||
data.databaseReady.Store(true)
|
||||
initialized, err = repo.IsInitialized(context.Background())
|
||||
initialized, err = data.IsInitialized(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInitialized() error = %v", err)
|
||||
}
|
||||
@@ -267,8 +267,7 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &initializationRepo{data: d}
|
||||
if err = repo.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil {
|
||||
if err = d.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package migration owns the application database migration runner. Schema
|
||||
// steps remain in internal/data because they need that package's private POs;
|
||||
// this package contains only the reusable versioning mechanism.
|
||||
// Package migration owns the application database migration runner. Each data
|
||||
// module declares its own steps; the root data package only orders and runs
|
||||
// them.
|
||||
package migration
|
||||
|
||||
import (
|
||||
|
||||
@@ -11,21 +11,13 @@ import (
|
||||
// migrateAll is the single data-layer migration entry point. Module-specific
|
||||
// schema work stays with the module that owns its persistent objects.
|
||||
func migrateAll(db *gorm.DB) error {
|
||||
return migration.Run(db, []migration.Step{
|
||||
{ID: "202608200001_baseline", Migrate: func(db *gorm.DB) error {
|
||||
if err := datasystem.LegacySchemaMigration(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return datapayment.Migrate(db)
|
||||
}},
|
||||
{ID: "202608200002_data_reconcile", Migrate: func(db *gorm.DB) error {
|
||||
if err := datapayment.Reconcile(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return datasystem.CurrentDataMigration(db)
|
||||
}},
|
||||
{ID: "202608200003_payment_admin_surface", Migrate: func(db *gorm.DB) error {
|
||||
return datasystem.EnsureAdminSurface(db, datapayment.AdminSurface())
|
||||
}},
|
||||
})
|
||||
steps := []migration.Step{{
|
||||
ID: "202608200001_data_infrastructure",
|
||||
Migrate: func(db *gorm.DB) error {
|
||||
return db.AutoMigrate(&integrationConfigPO{})
|
||||
},
|
||||
}}
|
||||
steps = append(steps, datasystem.Migrations()...)
|
||||
steps = append(steps, datapayment.Migrations()...)
|
||||
return migration.Run(db, steps)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package pagination contains GORM pagination helpers shared by repository
|
||||
// implementations in the data layer.
|
||||
package pagination
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// LimitOffset normalizes a page request and applies the optional maximum page
|
||||
// size. A non-positive size means that no limit should be applied.
|
||||
func LimitOffset(page, size, maxSize int) (limit, offset int) {
|
||||
limit = size
|
||||
if maxSize > 0 && size > maxSize {
|
||||
limit = maxSize
|
||||
}
|
||||
if limit <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
return limit, (page - 1) * limit
|
||||
}
|
||||
|
||||
// Apply applies pagination only when the caller supplied a positive size.
|
||||
func Apply(db *gorm.DB, page, size, maxSize int) *gorm.DB {
|
||||
limit, offset := LimitOffset(page, size, maxSize)
|
||||
if limit == 0 {
|
||||
return db
|
||||
}
|
||||
return db.Offset(offset).Limit(limit)
|
||||
}
|
||||
|
||||
// ApplyRequired always applies LIMIT/OFFSET, including LIMIT 0. This keeps
|
||||
// the existing repository contract for APIs whose page size is mandatory.
|
||||
func ApplyRequired(db *gorm.DB, page, size, maxSize int) *gorm.DB {
|
||||
limit, offset := LimitOffset(page, size, maxSize)
|
||||
return db.Offset(offset).Limit(limit)
|
||||
}
|
||||
@@ -1,31 +1,31 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"kra/internal/data/system"
|
||||
"kra/internal/adminsurface"
|
||||
"kra/internal/data/migration"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Migrate creates the persistence owned by the payment module.
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(&integrationConfigPO{}, &paymentOrderPO{})
|
||||
}
|
||||
|
||||
// Reconcile seeds disabled configuration rows for all built-in providers.
|
||||
func Reconcile(db *gorm.DB) error {
|
||||
return ensurePaymentIntegrationConfigs(db)
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{
|
||||
{ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error {
|
||||
return db.AutoMigrate(&paymentOrderPO{})
|
||||
}},
|
||||
{ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs},
|
||||
}
|
||||
}
|
||||
|
||||
// AdminSurface describes the payment-owned entries shown in the system
|
||||
// administration UI. The system module persists these records because it owns
|
||||
// the menu/API/policy tables.
|
||||
func AdminSurface() system.AdminSurface {
|
||||
return system.AdminSurface{
|
||||
Menus: []system.AdminMenu{
|
||||
func AdminSurface() adminsurface.Surface {
|
||||
return adminsurface.Surface{
|
||||
Menus: []adminsurface.Menu{
|
||||
{Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6},
|
||||
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
||||
},
|
||||
APIs: []system.AdminAPI{
|
||||
APIs: []adminsurface.API{
|
||||
{Path: "/payment/configs", Method: "GET", APIGroup: "支付", Description: "获取支付渠道配置"},
|
||||
{Path: "/payment/config", Method: "POST", APIGroup: "支付", Description: "保存支付渠道配置"},
|
||||
{Path: "/payment/orders", Method: "GET", APIGroup: "支付", Description: "分页查询支付订单"},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package payment
|
||||
|
||||
import "kra/pkg/paymentkit"
|
||||
import "kra/internal/utils/paymentutil"
|
||||
|
||||
func text(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
@@ -17,7 +17,7 @@ func firstAny(values map[string]any, keys ...string) string {
|
||||
}
|
||||
|
||||
// configuredInt64 keeps repository-side validation independent from the
|
||||
// provider adapter package while sharing the canonical paymentkit parser.
|
||||
// provider adapter package while sharing the canonical payment parser.
|
||||
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
|
||||
return paymentkit.ConfiguredInt64(values, key, fallback)
|
||||
return paymentutil.ConfiguredInt64(values, key, fallback)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
)
|
||||
|
||||
type paymentOrderPO struct {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz"
|
||||
)
|
||||
|
||||
type Data struct{ gormDB *reloadableDB }
|
||||
@@ -49,19 +48,13 @@ func openIntegrationConfigTestDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
|
||||
func migrateAll(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(&integrationConfigPO{}, &paymentOrderPO{}); err != nil {
|
||||
if err := db.AutoMigrate(&integrationConfigPO{}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, provider := range biz.SupportedPaymentProviders {
|
||||
var count int64
|
||||
if err := db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, provider).Count(&count).Error; err != nil {
|
||||
for _, step := range Migrations() {
|
||||
if err := step.Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Config: "{}"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/gormkit"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/gormkit"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -23,7 +23,7 @@ type announcementPO struct {
|
||||
Attachments gormkit.JSON
|
||||
}
|
||||
|
||||
func (announcementPO) TableName() string { return "kra_announcements_info" }
|
||||
func (announcementPO) TableName() string { return "sys_announcements" }
|
||||
|
||||
type announcementRepo struct{ data Provider }
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -10,31 +10,6 @@ type IgnoredAPI struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// AdminMenu describes a menu entry contributed by a module. ParentName is
|
||||
// resolved by the system persistence layer so feature modules do not need to
|
||||
// know the menu PO shape.
|
||||
type AdminMenu struct {
|
||||
Name string
|
||||
Path string
|
||||
ParentName string
|
||||
Component string
|
||||
Title string
|
||||
Icon string
|
||||
Sort int
|
||||
}
|
||||
|
||||
type AdminAPI struct {
|
||||
Path string
|
||||
Method string
|
||||
APIGroup string
|
||||
Description string
|
||||
}
|
||||
|
||||
type AdminSurface struct {
|
||||
Menus []AdminMenu
|
||||
APIs []AdminAPI
|
||||
}
|
||||
|
||||
func DefaultIgnoredAPIs(staticPath string) []IgnoredAPI {
|
||||
staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath"
|
||||
return []IgnoredAPI{
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -1,507 +1,30 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"kra/internal/data/migration"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// legacySchemaMigration creates the complete schema and repairs legacy table
|
||||
// shapes. It is intentionally called only by the one-time baseline migration.
|
||||
func LegacySchemaMigration(db *gorm.DB) error {
|
||||
if err := migrateLegacyIgnoreAPITable(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.AutoMigrate(
|
||||
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
|
||||
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
|
||||
&departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{},
|
||||
&dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &SecurityConfigPO{},
|
||||
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
|
||||
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
|
||||
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
|
||||
&announcementPO{},
|
||||
)
|
||||
}
|
||||
|
||||
// CurrentDataMigration contains idempotent system data and authorization
|
||||
// reconciliation that belongs to an explicit gormigrate version.
|
||||
func CurrentDataMigration(db *gorm.DB) error {
|
||||
if err := migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeErrorRecordStatuses(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reconcileRootAuthorityAPIs(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return reconcileReferenceIndexes(db)
|
||||
}
|
||||
|
||||
// EnsureAdminSurface upgrades an existing database with menu/API metadata
|
||||
// contributed by a feature module. The operation is idempotent and safe to run
|
||||
// before the root authority exists; bootstrap seeding links the full menu tree
|
||||
// when the first administrator is created.
|
||||
func EnsureAdminSurface(db *gorm.DB, surface AdminSurface) error {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
return clean.Transaction(func(tx *gorm.DB) error {
|
||||
menus := make([]menuPO, 0, len(surface.Menus))
|
||||
for _, item := range surface.Menus {
|
||||
parentID := uint(0)
|
||||
if item.ParentName != "" {
|
||||
var parent menuPO
|
||||
if err := tx.Where("name = ?", item.ParentName).First(&parent).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
parent = menuPO{Path: item.ParentName, Name: item.ParentName, Component: "view/routerHolder.vue", Title: item.ParentName, Sort: item.Sort}
|
||||
if err := tx.Create(&parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
parentID = parent.ID
|
||||
}
|
||||
menu := menuPO{MenuLevel: 1, ParentID: parentID, Path: item.Path, Name: item.Name, Component: item.Component, Title: item.Title, Icon: item.Icon, Sort: item.Sort}
|
||||
if item.ParentName == "" {
|
||||
menu.MenuLevel = 0
|
||||
}
|
||||
menus = append(menus, menu)
|
||||
}
|
||||
for _, item := range menus {
|
||||
var current menuPO
|
||||
err := tx.Where("name = ?", item.Name).First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
if err := tx.Model(¤t).Updates(map[string]any{
|
||||
"menu_level": item.MenuLevel, "parent_id": parent.ID, "path": item.Path,
|
||||
"component": item.Component, "title": item.Title, "icon": item.Icon, "sort": item.Sort,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apis := make([]apiPO, 0, len(surface.APIs))
|
||||
for _, item := range surface.APIs {
|
||||
apis = append(apis, apiPO{Path: item.Path, Method: item.Method, APIGroup: item.APIGroup, Description: item.Description})
|
||||
}
|
||||
for _, item := range apis {
|
||||
var current apiPO
|
||||
err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
if err := tx.Model(¤t).Updates(map[string]any{"api_group": item.APIGroup, "description": item.Description}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authorityCount int64
|
||||
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if authorityCount == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, item := range menus {
|
||||
var current menuPO
|
||||
if err := tx.Where("name = ?", item.Name).First(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, current.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if err := tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: current.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range apis {
|
||||
var current apiPO
|
||||
if err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
exists, err := policyExists(tx, 888, current.Path, current.Method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if err := tx.Create(&casbinRulePO{Ptype: "p", V0: "888", V1: current.Path, V2: current.Method}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// migrateLegacyAuthorityDepartmentColumns preserves data created by early
|
||||
// Kra builds, which used shortened join-column names and a composite primary
|
||||
// key. The administration connection model has neither a primary key nor a
|
||||
// uniqueness constraint, so rebuild the small table before AutoMigrate.
|
||||
func migrateLegacyAuthorityDepartmentColumns(db *gorm.DB) error {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
const (
|
||||
table = "sys_authority_departments"
|
||||
backup = "sys_authority_departments_kra_legacy"
|
||||
)
|
||||
// MySQL and Oracle auto-commit DDL. If a prior process stopped between the
|
||||
// rename and cleanup steps, restore the untouched backup first and retry the
|
||||
// migration from a known state.
|
||||
if clean.Migrator().HasTable(backup) {
|
||||
if clean.Migrator().HasTable(table) {
|
||||
if err := clean.Migrator().DropTable(table); err != nil {
|
||||
return fmt.Errorf("remove incomplete authority-department table: %w", err)
|
||||
}
|
||||
}
|
||||
if err := clean.Migrator().RenameTable(backup, table); err != nil {
|
||||
return fmt.Errorf("restore authority-department backup: %w", err)
|
||||
}
|
||||
}
|
||||
if !clean.Migrator().HasTable(table) {
|
||||
return nil
|
||||
}
|
||||
|
||||
authorityColumn := "sys_authority_authority_id"
|
||||
if !tableHasColumn(clean, table, authorityColumn) {
|
||||
if !tableHasColumn(clean, table, "authority_id") {
|
||||
return fmt.Errorf("authority-department table has no authority column")
|
||||
}
|
||||
authorityColumn = "authority_id"
|
||||
}
|
||||
departmentColumn := "sys_department_id"
|
||||
if !tableHasColumn(clean, table, departmentColumn) {
|
||||
if !tableHasColumn(clean, table, "department_id") {
|
||||
return fmt.Errorf("authority-department table has no department column")
|
||||
}
|
||||
departmentColumn = "department_id"
|
||||
}
|
||||
hasPrimaryKey, err := tableHasPrimaryKey(clean, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if authorityColumn == "sys_authority_authority_id" && departmentColumn == "sys_department_id" && !hasPrimaryKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
type relation struct {
|
||||
AuthorityID uint `gorm:"column:authority_id"`
|
||||
DepartmentID uint `gorm:"column:department_id"`
|
||||
}
|
||||
var rows []relation
|
||||
selectColumns := authorityColumn + " AS authority_id, " + departmentColumn + " AS department_id"
|
||||
if err := clean.Table(table).Select(selectColumns).Scan(&rows).Error; err != nil {
|
||||
return fmt.Errorf("read legacy authority-department rows: %w", err)
|
||||
}
|
||||
|
||||
rebuild := func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().RenameTable(table, backup); err != nil {
|
||||
return fmt.Errorf("rename legacy authority-department table: %w", err)
|
||||
}
|
||||
if err := tx.AutoMigrate(&authorityDepartmentPO{}); err != nil {
|
||||
return fmt.Errorf("create authority-department table: %w", err)
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
items := make([]authorityDepartmentPO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, authorityDepartmentPO{AuthorityID: row.AuthorityID, DepartmentID: row.DepartmentID})
|
||||
}
|
||||
if err := tx.Create(&items).Error; err != nil {
|
||||
return fmt.Errorf("copy authority-department rows: %w", err)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&authorityDepartmentPO{}).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("verify authority-department rows: %w", err)
|
||||
}
|
||||
if count != int64(len(rows)) {
|
||||
return fmt.Errorf("verify authority-department rows: got %d want %d", count, len(rows))
|
||||
}
|
||||
if err := tx.Migrator().DropTable(backup); err != nil {
|
||||
return fmt.Errorf("drop legacy authority-department table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch clean.Dialector.Name() {
|
||||
case "mysql", "oracle":
|
||||
if err := rebuild(clean); err != nil {
|
||||
restoreErr := restoreAuthorityDepartmentBackup(clean, table, backup)
|
||||
if restoreErr != nil {
|
||||
return fmt.Errorf("%v; restore authority-department backup: %w", err, restoreErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return clean.Transaction(rebuild)
|
||||
// Migrations returns schema steps owned by the built-in system module.
|
||||
// Administrators, menus, APIs, and policies are created only by SeedSystem
|
||||
// during the explicit first-install flow.
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{
|
||||
{
|
||||
ID: "202608200002_system_schema",
|
||||
Migrate: func(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
|
||||
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
|
||||
&departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{},
|
||||
&dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &SecurityConfigPO{},
|
||||
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
|
||||
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
|
||||
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
|
||||
&announcementPO{},
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func restoreAuthorityDepartmentBackup(db *gorm.DB, table, backup string) error {
|
||||
if db.Migrator().HasTable(table) {
|
||||
if err := db.Migrator().DropTable(table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable(backup) {
|
||||
return db.Migrator().RenameTable(backup, table)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableHasPrimaryKey(db *gorm.DB, table string) (bool, error) {
|
||||
columns, err := db.Migrator().ColumnTypes(table)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, column := range columns {
|
||||
if primary, ok := column.PrimaryKey(); ok && primary {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Older builds used a status label outside the administration page's supported
|
||||
// state set, so normalize existing rows during migration.
|
||||
func normalizeErrorRecordStatuses(db *gorm.DB) error {
|
||||
return db.Session(&gorm.Session{NewDB: true}).Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error
|
||||
}
|
||||
|
||||
// migrateLegacyAuthorityAPIsToCasbinRules upgrades the early Kra join-table
|
||||
// representation to the independent Casbin policy table. Keep the legacy
|
||||
// table in place for backwards compatibility, but make casbin_rule the sole
|
||||
// live policy source. Existing policy rows are not duplicated.
|
||||
func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&authorityAPIPO{}) || !clean.Migrator().HasTable(&casbinRulePO{}) {
|
||||
return nil
|
||||
}
|
||||
type legacyPolicy struct {
|
||||
AuthorityID uint
|
||||
Path string
|
||||
Method string
|
||||
}
|
||||
baseQuery := func() *gorm.DB {
|
||||
return clean.Table("sys_authority_apis sa").
|
||||
Select("sa.authority_id, a.path, a.method").
|
||||
Joins("JOIN sys_apis a ON a.id = sa.api_id")
|
||||
}
|
||||
query := baseQuery()
|
||||
// Early Kra schemas stored sys_apis without soft-delete timestamps. The
|
||||
// legacy-policy migration must run before assuming that column exists;
|
||||
// otherwise an upgrade from those schemas cannot start on MySQL.
|
||||
if tableHasColumn(clean, "sys_apis", "deleted_at") {
|
||||
query = query.Where("a.deleted_at IS NULL")
|
||||
}
|
||||
var rows []legacyPolicy
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
// A few MySQL-compatible drivers report stale/incomplete metadata from
|
||||
// INFORMATION_SCHEMA during startup. If the optional soft-delete column
|
||||
// was reported present but the join still rejects it, retry using only
|
||||
// columns shared by every legacy schema. This migration must never block
|
||||
// startup of an older database solely because deleted_at is absent.
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unknown column") && strings.Contains(strings.ToLower(err.Error()), "deleted_at") {
|
||||
if retryErr := baseQuery().Find(&rows).Error; retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if err := clean.Create(&casbinRulePO{Ptype: "p", V0: fmt.Sprint(row.AuthorityID), V1: row.Path, V2: row.Method}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableHasColumn deliberately inspects the physical table rather than the
|
||||
// model schema. Legacy databases may predate soft-delete columns even though
|
||||
// the current PO includes gorm.DeletedAt. Metadata inspection failures are
|
||||
// treated as "unknown" so callers use the portable query shape.
|
||||
func tableHasColumn(db *gorm.DB, table, column string) bool {
|
||||
columns, err := db.Migrator().ColumnTypes(table)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range columns {
|
||||
if strings.EqualFold(item.Name(), column) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key
|
||||
// shape (path, method) to the compatible model shape (ID/timestamps/soft
|
||||
// delete). AutoMigrate can add columns but cannot replace an existing
|
||||
// composite primary key portably, so rebuild the small table once while
|
||||
// preserving every existing ignore rule.
|
||||
func migrateLegacyIgnoreAPITable(db *gorm.DB) error {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&ignoredAPIPO{}) || clean.Migrator().HasColumn(&ignoredAPIPO{}, "id") {
|
||||
return nil
|
||||
}
|
||||
legacyTable := fmt.Sprintf("sys_ignore_apis_legacy_%d", time.Now().UnixNano())
|
||||
type legacyIgnoredAPI struct {
|
||||
Path string
|
||||
Method string
|
||||
}
|
||||
return clean.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().RenameTable(ignoredAPIPO{}.TableName(), legacyTable); err != nil {
|
||||
return fmt.Errorf("rename legacy ignore API table: %w", err)
|
||||
}
|
||||
if err := tx.AutoMigrate(&ignoredAPIPO{}); err != nil {
|
||||
return fmt.Errorf("create compatible ignore API table: %w", err)
|
||||
}
|
||||
var rows []legacyIgnoredAPI
|
||||
if err := tx.Table(legacyTable).Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("read legacy ignore API rows: %w", err)
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
items := make([]ignoredAPIPO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, ignoredAPIPO{Path: row.Path, Method: row.Method})
|
||||
}
|
||||
if err := tx.Create(&items).Error; err != nil {
|
||||
return fmt.Errorf("copy legacy ignore API rows: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Migrator().DropTable(legacyTable); err != nil {
|
||||
return fmt.Errorf("drop legacy ignore API table: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// reconcileRootAuthorityAPIs is a one-time upgrade path from the former Kra
|
||||
// implementation where authority 888 bypassed policy storage entirely. The compatible behavior
|
||||
// grants its root role through persisted Casbin policies, so when a legacy
|
||||
// database has the root role but no stored API links, materialize the same
|
||||
// policy set and let normal authorization read it thereafter.
|
||||
func reconcileRootAuthorityAPIs(db *gorm.DB) error {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
var authorityCount int64
|
||||
if err := clean.Session(&gorm.Session{NewDB: true}).Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil || authorityCount == 0 {
|
||||
return err
|
||||
}
|
||||
var policyCount int64
|
||||
if err := policyScope(clean).Where("v0 = ?", "888").Count(&policyCount).Error; err != nil || policyCount != 0 {
|
||||
return err
|
||||
}
|
||||
var ignored []ignoredAPIPO
|
||||
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&ignored).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ignoreSet := make(map[string]struct{}, len(ignored))
|
||||
for _, item := range ignored {
|
||||
ignoreSet[item.Method+"\x00"+item.Path] = struct{}{}
|
||||
}
|
||||
var apis []apiPO
|
||||
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&apis).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rules := make([]casbinRulePO, 0, len(apis))
|
||||
for _, api := range apis {
|
||||
if _, ok := ignoreSet[api.Method+"\x00"+api.Path]; ok {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, newPolicyRule(888, api.Path, api.Method))
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
return clean.Session(&gorm.Session{NewDB: true}).Create(&rules).Error
|
||||
}
|
||||
|
||||
// reconcileReferenceIndexes removes constraints created by older Kra builds
|
||||
// that are not part of the administration data model. Business services own
|
||||
// duplicate checks and their user-facing error messages.
|
||||
func reconcileReferenceIndexes(db *gorm.DB) error {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
obsolete := []struct {
|
||||
model any
|
||||
name string
|
||||
}{
|
||||
{&apiPO{}, "idx_api_path_method"},
|
||||
{&dictionaryPO{}, "idx_sys_dictionaries_type"},
|
||||
{¶meterPO{}, "idx_sys_params_key"},
|
||||
{&apiTokenPO{}, "idx_sys_api_tokens_token"},
|
||||
{&exportTemplatePO{}, "idx_sys_export_templates_template_id"},
|
||||
}
|
||||
for _, item := range obsolete {
|
||||
migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator()
|
||||
if migrator.HasIndex(item.model, item.name) {
|
||||
if err := migrator.DropIndex(item.model, item.name); err != nil {
|
||||
return fmt.Errorf("drop obsolete index %s: %w", item.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
field string
|
||||
}{{"idx_sys_users_uuid", "UUID"}, {"idx_sys_users_username", "Username"}} {
|
||||
unique, err := indexIsUnique(clean.Session(&gorm.Session{NewDB: true}), &userPO{}, item.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !unique {
|
||||
continue
|
||||
}
|
||||
migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator()
|
||||
if err = migrator.DropIndex(&userPO{}, item.name); err != nil {
|
||||
return fmt.Errorf("drop legacy unique index %s: %w", item.name, err)
|
||||
}
|
||||
if err = migrator.CreateIndex(&userPO{}, item.field); err != nil {
|
||||
return fmt.Errorf("create reference index %s: %w", item.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func indexIsUnique(db *gorm.DB, model any, name string) (bool, error) {
|
||||
indexes, err := db.Migrator().GetIndexes(model)
|
||||
if err != nil {
|
||||
// Some third-party GORM drivers do not implement index inspection.
|
||||
// Fresh schemas are already correct; skip only the legacy repair there.
|
||||
return false, nil
|
||||
}
|
||||
for _, index := range indexes {
|
||||
if index.Name() == name {
|
||||
unique, known := index.Unique()
|
||||
return known && unique, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -1,265 +1,31 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"testing"
|
||||
import "testing"
|
||||
|
||||
"kra/internal/data/migration"
|
||||
)
|
||||
|
||||
func TestMigrateAllUsesVersionTableAndIsIdempotent(t *testing.T) {
|
||||
func TestMigrationsCreateOnlySystemSchema(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if err = migrateAll(db); err != nil {
|
||||
t.Fatalf("first migration: %v", err)
|
||||
}
|
||||
if err = migrateAll(db); err != nil {
|
||||
t.Fatalf("second migration: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasTable(migration.TableName) {
|
||||
t.Fatalf("missing migration table %q", migration.TableName)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Table(migration.TableName).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("migration rows = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePaymentAdminSurfaceIsIdempotent(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err = db.AutoMigrate(&menuPO{}, &authorityMenuPO{}, &authorityPO{}, &apiPO{}, &casbinRulePO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootParentID := uint(0)
|
||||
if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root", ParentID: &rootParentID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&menuPO{Name: "extensions", Path: "legacy-extensions", Title: "旧扩展"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&menuPO{Name: "paymentOrders", Component: "legacy.vue", Title: "旧支付订单"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&apiPO{Path: "/payment/configs", Method: "GET", APIGroup: "legacy"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = ensurePaymentAdminSurface(db); err != nil {
|
||||
t.Fatalf("ensure payment admin surface pass %d: %v", i+1, err)
|
||||
for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis", "sys_departments", "sys_announcements"} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
t.Fatalf("system migration did not create %s", table)
|
||||
}
|
||||
}
|
||||
|
||||
var parent menuPO
|
||||
if err = db.Where("name = ?", "extensions").First(&parent).Error; err != nil {
|
||||
var users, menus, apis int64
|
||||
if err = db.Model(&userPO{}).Count(&users).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 {
|
||||
if err = db.Model(&menuPO{}).Count(&menus).Error; 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 {
|
||||
if err = db.Model(&apiPO{}).Count(&apis).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("duplicate relation count = %d, want 2", count)
|
||||
if users != 0 || menus != 0 || apis != 0 {
|
||||
t.Fatalf("schema migration seeded business data: users=%d menus=%d apis=%d", users, menus, apis)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
"kra/pkg/adminauth"
|
||||
"kra/internal/security/adminauth"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/adminsurface"
|
||||
"kra/internal/biz"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) error {
|
||||
func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig, surfaces ...adminsurface.Surface) error {
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
rootParentID := uint(0)
|
||||
authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", ParentID: &rootParentID, DataScope: 1, DefaultRouter: "dashboard"}
|
||||
@@ -28,6 +29,11 @@ func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) err
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, surface := range surfaces {
|
||||
if err := seedAdminSurface(tx, surface); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var persisted []menuPO
|
||||
if err := tx.Order("sort asc, id asc").Find(&persisted).Error; err != nil {
|
||||
return err
|
||||
@@ -140,6 +146,47 @@ func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) err
|
||||
})
|
||||
}
|
||||
|
||||
func seedAdminSurface(tx *gorm.DB, surface adminsurface.Surface) error {
|
||||
for _, item := range surface.Menus {
|
||||
parentID := uint(0)
|
||||
if item.ParentName != "" {
|
||||
var parent menuPO
|
||||
if err := tx.Where("name = ?", item.ParentName).First(&parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
parentID = parent.ID
|
||||
}
|
||||
menu := menuPO{
|
||||
MenuLevel: 1,
|
||||
ParentID: parentID,
|
||||
Path: item.Path,
|
||||
Name: item.Name,
|
||||
Component: item.Component,
|
||||
Title: item.Title,
|
||||
Icon: item.Icon,
|
||||
Sort: item.Sort,
|
||||
}
|
||||
if item.ParentName == "" {
|
||||
menu.MenuLevel = 0
|
||||
}
|
||||
if err := tx.Where("name = ?", item.Name).FirstOrCreate(&menu).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range surface.APIs {
|
||||
api := apiPO{
|
||||
Path: item.Path,
|
||||
Method: strings.ToUpper(item.Method),
|
||||
APIGroup: item.APIGroup,
|
||||
Description: item.Description,
|
||||
}
|
||||
if err := tx.Where("path = ? AND method = ?", api.Path, api.Method).FirstOrCreate(&api).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultMenus() []menuPO {
|
||||
root := func(path, name, title, icon string, sort int) menuPO {
|
||||
return menuPO{Path: path, Name: name, Component: "view/routerHolder.vue", Title: title, Icon: icon, Sort: sort}
|
||||
|
||||
@@ -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 (
|
||||
"context"
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/gormkit"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/gormkit"
|
||||
"kra/internal/data/pagination"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/data/migration"
|
||||
)
|
||||
|
||||
// Data is a small in-package test harness. Production repositories depend on
|
||||
@@ -69,8 +68,10 @@ func openWithDriver(driver, dsn string) (*gorm.DB, error) {
|
||||
}
|
||||
|
||||
func migrateAll(db *gorm.DB) error {
|
||||
return migration.Run(db, []migration.Step{
|
||||
{ID: "test_baseline", Migrate: LegacySchemaMigration},
|
||||
{ID: "test_reconcile", Migrate: CurrentDataMigration},
|
||||
})
|
||||
for _, step := range Migrations() {
|
||||
if err := step.Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/pagination"
|
||||
"kra/internal/data/pagination"
|
||||
)
|
||||
|
||||
type versionPO struct {
|
||||
|
||||
Reference in New Issue
Block a user