优化结构
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
// Package app is the composition root for the running administration service.
|
||||
// It wires runtime objects that need constructed dependencies. Static module
|
||||
// declarations live in the sibling internal/modules package.
|
||||
package app
|
||||
|
||||
import (
|
||||
"kra/pkg/module"
|
||||
platformtask "kra/pkg/task"
|
||||
)
|
||||
|
||||
// TaskRegistry builds the process-wide registry from dependency-free module
|
||||
// contributions. Dependency-bearing methods are added by their module runtime
|
||||
// constructors after the usecases have been created.
|
||||
func TaskRegistry(catalog module.Catalog) *platformtask.Registry {
|
||||
registry := platformtask.NewRegistry()
|
||||
registry.RegisterAll(catalog.TaskMethods())
|
||||
return registry
|
||||
}
|
||||
|
||||
// Composition groups runtime objects that need constructed dependencies. The
|
||||
// binary composition root supplies the concrete modules.
|
||||
type Composition struct {
|
||||
Routes []module.RouteRegistrar
|
||||
Tasks []platformtask.Contributor
|
||||
}
|
||||
|
||||
// Build activates dependency-bearing tasks and composes module routes.
|
||||
func Build(contributions Composition, registry *platformtask.Registry) *module.Runtime {
|
||||
platformtask.Apply(registry, contributions.Tasks...)
|
||||
return module.NewRuntime(contributions.Routes...)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/pkg/module"
|
||||
platformtask "kra/pkg/task"
|
||||
)
|
||||
|
||||
func TestTaskRegistryRegistersStaticModuleMethods(t *testing.T) {
|
||||
method := platformtask.Method{
|
||||
Name: "test.static",
|
||||
Run: func(context.Context, json.RawMessage) error { return nil },
|
||||
}
|
||||
catalog := module.Catalog{Definitions: []module.Definition{{Tasks: []platformtask.Method{method}}}}
|
||||
registry := TaskRegistry(catalog)
|
||||
if _, ok := registry.Lookup(method.Name); !ok {
|
||||
t.Fatalf("method %q was not registered", method.Name)
|
||||
}
|
||||
}
|
||||
|
||||
type testRouteRegistrar struct{ called bool }
|
||||
|
||||
func (registrar *testRouteRegistrar) RegisterRoutes(*gin.RouterGroup, *gin.RouterGroup, *gin.Engine) {
|
||||
registrar.called = true
|
||||
}
|
||||
|
||||
type testTaskContributor struct{ name string }
|
||||
|
||||
func (contributor testTaskContributor) RegisterTasks(registry *platformtask.Registry) {
|
||||
registry.Register(platformtask.Method{
|
||||
Name: contributor.name,
|
||||
Run: func(context.Context, json.RawMessage) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func TestRuntimeAppliesAllContributions(t *testing.T) {
|
||||
registry := platformtask.NewRegistry()
|
||||
route := &testRouteRegistrar{}
|
||||
runtime := Build(Composition{
|
||||
Routes: []module.RouteRegistrar{route},
|
||||
Tasks: []platformtask.Contributor{testTaskContributor{name: "test.runtime"}},
|
||||
}, registry)
|
||||
|
||||
if _, ok := registry.Lookup("test.runtime"); !ok {
|
||||
t.Fatal("runtime task contributor was not applied")
|
||||
}
|
||||
runtime.RegisterRoutes(nil, nil, nil)
|
||||
if !route.called {
|
||||
t.Fatal("runtime route contributor was not called")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cache is the shared cache seam. The data implementation uses Redis when it
|
||||
// is reachable and falls back to process memory for local development.
|
||||
type Cache interface {
|
||||
Get(context.Context, string) (string, bool, error)
|
||||
Set(context.Context, string, string, time.Duration) error
|
||||
Delete(context.Context, string) error
|
||||
Increment(context.Context, string, time.Duration) (int64, error)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cache is the shared cache seam. The data implementation uses Redis when it
|
||||
// is reachable and falls back to process memory for local development.
|
||||
type Cache interface {
|
||||
Get(context.Context, string) (string, bool, error)
|
||||
Set(context.Context, string, string, time.Duration) error
|
||||
Delete(context.Context, string) error
|
||||
Increment(context.Context, string, time.Duration) (int64, error)
|
||||
}
|
||||
|
||||
type StoredFile struct {
|
||||
Name string
|
||||
Path string
|
||||
URL string
|
||||
Size int64
|
||||
LastModified time.Time
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// FileStorage owns the persistence boundary for uploaded files.
|
||||
type FileStorage interface {
|
||||
Put(context.Context, string, io.Reader) (*StoredFile, error)
|
||||
Open(context.Context, string) (io.ReadCloser, error)
|
||||
Delete(context.Context, string) error
|
||||
Compose(context.Context, []string, string) (*StoredFile, string, error)
|
||||
DeletePrefix(context.Context, string) error
|
||||
List(context.Context, string, string, int) ([]*StoredFile, string, bool, error)
|
||||
}
|
||||
|
||||
type JWTSettings struct {
|
||||
SigningKey string
|
||||
Issuer string
|
||||
Expires time.Duration
|
||||
Buffer time.Duration
|
||||
}
|
||||
|
||||
type CaptchaSettings struct {
|
||||
KeyLong int
|
||||
ImageWidth int
|
||||
ImageHeight int
|
||||
StoreExpiration time.Duration
|
||||
}
|
||||
|
||||
type MediaSettings struct {
|
||||
SessionTTL int
|
||||
MaxFileSize int64
|
||||
ChunkDir string
|
||||
}
|
||||
|
||||
const DefaultMaxMediaFileSize int64 = 100 << 20
|
||||
|
||||
func (s MediaSettings) EffectiveMaxFileSize() int64 {
|
||||
if s.MaxFileSize > 0 {
|
||||
return s.MaxFileSize
|
||||
}
|
||||
return DefaultMaxMediaFileSize
|
||||
}
|
||||
|
||||
// RuntimeSettings exposes only the active values needed by the application.
|
||||
// The data implementation resolves every call from config.Store so hot reloads
|
||||
// take effect without rebuilding services.
|
||||
type RuntimeSettings interface {
|
||||
RouterPrefix() string
|
||||
JWTSettings() JWTSettings
|
||||
CaptchaSettings() CaptchaSettings
|
||||
MediaSettings() MediaSettings
|
||||
UseMultipoint() bool
|
||||
}
|
||||
|
||||
type IssuedToken struct {
|
||||
Value string
|
||||
ExpiresAt time.Time
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
type AuthClaims struct {
|
||||
UUID string
|
||||
ID uint
|
||||
Username string
|
||||
NickName string
|
||||
AuthorityID uint
|
||||
UserType string
|
||||
BufferTime time.Duration
|
||||
MustChangePwd bool
|
||||
PasswordVersion int64
|
||||
Issuer string
|
||||
Audience []string
|
||||
IssuedAt time.Time
|
||||
NotBefore time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrTokenMalformed = errors.New("token malformed")
|
||||
ErrTokenSignatureInvalid = errors.New("token signature invalid")
|
||||
ErrTokenNotValidYet = errors.New("token not valid yet")
|
||||
ErrTokenInvalid = errors.New("token invalid")
|
||||
ErrTokenDisabled = errors.New("token disabled")
|
||||
)
|
||||
|
||||
type TokenIssuer interface {
|
||||
IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error)
|
||||
ReissueToken(*AuthClaims, uint) (*IssuedToken, error)
|
||||
ParseToken(string) (*AuthClaims, error)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package system
|
||||
|
||||
import "time"
|
||||
|
||||
type JWTSettings struct {
|
||||
SigningKey string
|
||||
Issuer string
|
||||
Expires time.Duration
|
||||
Buffer time.Duration
|
||||
}
|
||||
|
||||
type CaptchaSettings struct {
|
||||
KeyLong int
|
||||
ImageWidth int
|
||||
ImageHeight int
|
||||
StoreExpiration time.Duration
|
||||
}
|
||||
|
||||
type MediaSettings struct {
|
||||
SessionTTL int
|
||||
MaxFileSize int64
|
||||
ChunkDir string
|
||||
}
|
||||
|
||||
const DefaultMaxMediaFileSize int64 = 100 << 20
|
||||
|
||||
func (s MediaSettings) EffectiveMaxFileSize() int64 {
|
||||
if s.MaxFileSize > 0 {
|
||||
return s.MaxFileSize
|
||||
}
|
||||
return DefaultMaxMediaFileSize
|
||||
}
|
||||
|
||||
// RuntimeSettings exposes only the active values needed by the application.
|
||||
// The data implementation resolves every call from config.Store so hot reloads
|
||||
// take effect without rebuilding services.
|
||||
type RuntimeSettings interface {
|
||||
RouterPrefix() string
|
||||
JWTSettings() JWTSettings
|
||||
CaptchaSettings() CaptchaSettings
|
||||
MediaSettings() MediaSettings
|
||||
UseMultipoint() bool
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StoredFile struct {
|
||||
Name string
|
||||
Path string
|
||||
URL string
|
||||
Size int64
|
||||
LastModified time.Time
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// FileStorage owns the persistence boundary for uploaded files.
|
||||
type FileStorage interface {
|
||||
Put(context.Context, string, io.Reader) (*StoredFile, error)
|
||||
Open(context.Context, string) (io.ReadCloser, error)
|
||||
Delete(context.Context, string) error
|
||||
Compose(context.Context, []string, string) (*StoredFile, string, error)
|
||||
DeletePrefix(context.Context, string) error
|
||||
List(context.Context, string, string, int) ([]*StoredFile, string, bool, error)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The token contract lives apart from the other infrastructure seams because
|
||||
// the transport middleware and every service that authenticates a request reach
|
||||
// for these types and errors without caring about cache or storage.
|
||||
|
||||
type IssuedToken struct {
|
||||
Value string
|
||||
ExpiresAt time.Time
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
type AuthClaims struct {
|
||||
UUID string
|
||||
ID uint
|
||||
Username string
|
||||
NickName string
|
||||
AuthorityID uint
|
||||
UserType string
|
||||
BufferTime time.Duration
|
||||
MustChangePwd bool
|
||||
PasswordVersion int64
|
||||
Issuer string
|
||||
Audience []string
|
||||
IssuedAt time.Time
|
||||
NotBefore time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrTokenMalformed = errors.New("token malformed")
|
||||
ErrTokenSignatureInvalid = errors.New("token signature invalid")
|
||||
ErrTokenNotValidYet = errors.New("token not valid yet")
|
||||
ErrTokenInvalid = errors.New("token invalid")
|
||||
ErrTokenDisabled = errors.New("token disabled")
|
||||
)
|
||||
|
||||
type TokenIssuer interface {
|
||||
IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error)
|
||||
ReissueToken(*AuthClaims, uint) (*IssuedToken, error)
|
||||
ParseToken(string) (*AuthClaims, error)
|
||||
}
|
||||
+10
-39
@@ -1,41 +1,12 @@
|
||||
package config
|
||||
|
||||
// Clone returns a deep copy of a configuration snapshot. The copy helpers are
|
||||
// intentionally kept in the config package so data/integration code does not
|
||||
// need protobuf cloning or storage-specific serializers.
|
||||
func Clone(value *Config) *Config { return cloneConfig(value) }
|
||||
|
||||
func CloneData(value *Data) *Data {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Data: value}).Data
|
||||
}
|
||||
|
||||
func CloneAdmin(value *Admin) *Admin {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Admin: value}).Admin
|
||||
}
|
||||
|
||||
func CloneDatabase(value *Database) *Database {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Data: &Data{Database: value}}).Data.Database
|
||||
}
|
||||
|
||||
func CloneStorage(value *Storage) *Storage {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Admin: &Admin{Storage: value}}).Admin.Storage
|
||||
}
|
||||
|
||||
func CloneEmail(value *Email) *Email {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Admin: &Admin{Email: value}}).Admin.Email
|
||||
}
|
||||
// The exported clone surface. Deep copying lives in the config package so
|
||||
// data/integration code needs neither protobuf cloning nor a storage-specific
|
||||
// serializer to hand out a snapshot a caller may safely mutate. Each function
|
||||
// returns nil for a nil input.
|
||||
func Clone(value *Config) *Config { return cloneConfig(value) }
|
||||
func CloneData(value *Data) *Data { return cloneData(value) }
|
||||
func CloneAdmin(value *Admin) *Admin { return cloneAdmin(value) }
|
||||
func CloneDatabase(value *Database) *Database { return clonePtr(value) }
|
||||
func CloneStorage(value *Storage) *Storage { return cloneStorage(value) }
|
||||
func CloneEmail(value *Email) *Email { return clonePtr(value) }
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Document is config.yaml as an editable YAML tree. Persisting through the tree
|
||||
// rather than re-marshalling a Config keeps two things the typed model cannot
|
||||
// represent: keys this program does not know about, and the user's comments.
|
||||
// Loading lives next to it in this package so one place owns the file format.
|
||||
type Document struct {
|
||||
path string
|
||||
node yaml.Node
|
||||
}
|
||||
|
||||
func OpenDocument(path string) (*Document, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document := &Document{path: path}
|
||||
if err = yaml.Unmarshal(raw, &document.node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
// Set replaces a top-level section, merging into whatever is already there.
|
||||
// The value is normalized through a generic map so a typed struct and a
|
||||
// hand-built map produce the same tree.
|
||||
func (d *Document) Set(key string, value any) error {
|
||||
normalized, err := normalizeYAML(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return setYAMLMapping(&d.node, key, normalized)
|
||||
}
|
||||
|
||||
// Delete removes the nested key path if present.
|
||||
func (d *Document) Delete(keys ...string) { deleteYAMLMapping(&d.node, keys...) }
|
||||
|
||||
// Has reports whether the nested key path exists.
|
||||
func (d *Document) Has(keys ...string) bool { return yamlMappingValue(&d.node, keys...) != nil }
|
||||
|
||||
// SetServerHTTPPort rewrites only the port of server.http.addr, keeping the
|
||||
// host the user configured. A non-positive port is ignored.
|
||||
func (d *Document) SetServerHTTPPort(port int) error {
|
||||
if port <= 0 {
|
||||
return nil
|
||||
}
|
||||
host := "0.0.0.0"
|
||||
if addr := yamlMappingValue(&d.node, "server", "http", "addr"); addr != nil {
|
||||
if currentHost, _, err := net.SplitHostPort(addr.Value); err == nil && currentHost != "" {
|
||||
host = currentHost
|
||||
}
|
||||
}
|
||||
return d.Set("server", map[string]any{"http": map[string]any{"addr": net.JoinHostPort(host, strconv.Itoa(port))}})
|
||||
}
|
||||
|
||||
// Save writes the document through a temporary file so a crash mid-write can
|
||||
// never leave a truncated config.yaml behind.
|
||||
func (d *Document) Save() error {
|
||||
output, err := yaml.Marshal(&d.node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(d.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(d.path), ".kra-config-*.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempName := temporary.Name()
|
||||
defer os.Remove(tempName)
|
||||
if _, err = temporary.Write(output); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err = temporary.Chmod(0o600); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err = temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tempName, d.path)
|
||||
}
|
||||
|
||||
func normalizeYAML(input any) (map[string]any, error) {
|
||||
raw, err := yaml.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var output map[string]any
|
||||
if err = yaml.Unmarshal(raw, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func setYAMLMapping(node *yaml.Node, key string, value any) error {
|
||||
if node.Kind == yaml.DocumentNode {
|
||||
node = node.Content[0]
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("configuration root is not a mapping")
|
||||
}
|
||||
raw, err := yaml.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var replacement yaml.Node
|
||||
if err = yaml.Unmarshal(raw, &replacement); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
mergeYAMLNode(node.Content[i+1], replacement.Content[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, replacement.Content[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeYAMLNode updates values produced from the runtime configuration while
|
||||
// retaining keys that are not represented by the typed configuration and
|
||||
// comments that were already present in the user's config file.
|
||||
func mergeYAMLNode(dst, src *yaml.Node) {
|
||||
if dst.Kind == yaml.MappingNode && src.Kind == yaml.MappingNode {
|
||||
for i := 0; i+1 < len(src.Content); i += 2 {
|
||||
key := src.Content[i].Value
|
||||
found := false
|
||||
for j := 0; j+1 < len(dst.Content); j += 2 {
|
||||
if dst.Content[j].Value == key {
|
||||
mergeYAMLNode(dst.Content[j+1], src.Content[i+1])
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
dst.Content = append(dst.Content, src.Content[i], src.Content[i+1])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Keep comments attached to a scalar/sequence node when its value changes.
|
||||
head, line, foot := dst.HeadComment, dst.LineComment, dst.FootComment
|
||||
*dst = *src
|
||||
dst.HeadComment, dst.LineComment, dst.FootComment = head, line, foot
|
||||
}
|
||||
|
||||
func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
node = node.Content[0]
|
||||
}
|
||||
for _, key := range keys {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
var next *yaml.Node
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
next = node.Content[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
node = next
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func deleteYAMLMapping(node *yaml.Node, keys ...string) {
|
||||
if len(keys) == 0 || node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
node = node.Content[0]
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return
|
||||
}
|
||||
key := keys[0]
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value != key {
|
||||
continue
|
||||
}
|
||||
if len(keys) == 1 {
|
||||
node.Content = append(node.Content[:i], node.Content[i+2:]...)
|
||||
return
|
||||
}
|
||||
deleteYAMLMapping(node.Content[i+1], keys[1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDocumentPreservesUnknownKeysAndUpdatesHTTPPort(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
input := "# keep this comment\nserver:\n http:\n addr: 127.0.0.1:8000\ncustom:\n enabled: true\n"
|
||||
if err := os.WriteFile(path, []byte(input), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document, err := OpenDocument(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = document.SetServerHTTPPort(9000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = document.Set("admin", map[string]any{"router_prefix": "/api"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = document.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(loaded)
|
||||
for _, want := range []string{"127.0.0.1:9000", "custom:", "enabled: true", "admin:", "router_prefix: /api"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("saved document missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-126
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -128,7 +129,7 @@ func (r *Store) UpdateDatabase(database *Database) {
|
||||
if config.Data == nil {
|
||||
config.Data = &Data{}
|
||||
}
|
||||
config.Data.Database = cloneDatabase(database)
|
||||
config.Data.Database = clonePtr(database)
|
||||
r.replace(config, false)
|
||||
}
|
||||
|
||||
@@ -358,162 +359,115 @@ func decode(v *viper.Viper) (*Config, error) {
|
||||
}
|
||||
|
||||
func setConfigPath(config *Config, path string) {
|
||||
if config != nil && config.Admin != nil {
|
||||
config.Admin.ConfigPath = path
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
if config.Admin == nil {
|
||||
config.Admin = &Admin{}
|
||||
}
|
||||
config.Admin.ConfigPath = path
|
||||
}
|
||||
|
||||
// Every config type is plain data: scalars, pointers to structs, and slices of
|
||||
// those. clonePtr and cloneSlice cover both shapes, so a clone function below is
|
||||
// one line per pointer or slice field and nothing else. A field holding only
|
||||
// scalars needs no line at all — the struct assignment already copied it.
|
||||
func clonePtr[T any](value *T) *T {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func cloneSlice[T any](values []*T, clone func(*T) *T) []*T {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make([]*T, len(values))
|
||||
for i, item := range values {
|
||||
copied[i] = clone(item)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func cloneConfig(value *Config) *Config {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
// Viper's mapstructure output contains only value fields and pointers;
|
||||
// round-tripping through a temporary map is unnecessarily lossy for
|
||||
// durations. Explicit copy via YAML gives us a robust deep copy while
|
||||
// preserving time.Duration values through the text hook.
|
||||
copy := *value
|
||||
if value.Server != nil {
|
||||
server := *value.Server
|
||||
copy.Server = &server
|
||||
if value.Server.HTTP != nil {
|
||||
http := *value.Server.HTTP
|
||||
copy.Server.HTTP = &http
|
||||
}
|
||||
copied := *value
|
||||
copied.Server = clonePtr(value.Server)
|
||||
if copied.Server != nil {
|
||||
copied.Server.HTTP = clonePtr(value.Server.HTTP)
|
||||
}
|
||||
if value.Data != nil {
|
||||
data := *value.Data
|
||||
copy.Data = &data
|
||||
data.Database = cloneDatabase(value.Data.Database)
|
||||
data.Redis = cloneRedis(value.Data.Redis)
|
||||
data.Mongo = cloneMongo(value.Data.Mongo)
|
||||
data.DatabaseList = make([]*Database, len(value.Data.DatabaseList))
|
||||
for i, item := range value.Data.DatabaseList {
|
||||
data.DatabaseList[i] = cloneDatabase(item)
|
||||
}
|
||||
data.RedisList = make([]*Redis, len(value.Data.RedisList))
|
||||
for i, item := range value.Data.RedisList {
|
||||
data.RedisList[i] = cloneRedis(item)
|
||||
}
|
||||
}
|
||||
if value.Admin != nil {
|
||||
copy.Admin = cloneAdmin(value.Admin)
|
||||
}
|
||||
return ©
|
||||
copied.Data = cloneData(value.Data)
|
||||
copied.Admin = cloneAdmin(value.Admin)
|
||||
return &copied
|
||||
}
|
||||
|
||||
func cloneDatabase(value *Database) *Database {
|
||||
func cloneData(value *Data) *Data {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
copied := *value
|
||||
copied.Database = clonePtr(value.Database)
|
||||
copied.Redis = cloneRedis(value.Redis)
|
||||
copied.Mongo = cloneMongo(value.Mongo)
|
||||
copied.DatabaseList = cloneSlice(value.DatabaseList, clonePtr)
|
||||
copied.RedisList = cloneSlice(value.RedisList, cloneRedis)
|
||||
return &copied
|
||||
}
|
||||
|
||||
func cloneRedis(value *Redis) *Redis {
|
||||
if value == nil {
|
||||
return nil
|
||||
copied := clonePtr(value)
|
||||
if copied != nil {
|
||||
copied.ClusterAddrs = slices.Clone(value.ClusterAddrs)
|
||||
}
|
||||
copy := *value
|
||||
copy.ClusterAddrs = append([]string(nil), value.ClusterAddrs...)
|
||||
return ©
|
||||
return copied
|
||||
}
|
||||
|
||||
func cloneMongo(value *Mongo) *Mongo {
|
||||
if value == nil {
|
||||
return nil
|
||||
copied := clonePtr(value)
|
||||
if copied != nil {
|
||||
copied.Hosts = cloneSlice(value.Hosts, clonePtr)
|
||||
}
|
||||
copy := *value
|
||||
copy.Hosts = make([]*MongoHost, len(value.Hosts))
|
||||
for i, item := range value.Hosts {
|
||||
if item != nil {
|
||||
host := *item
|
||||
copy.Hosts[i] = &host
|
||||
}
|
||||
}
|
||||
return ©
|
||||
return copied
|
||||
}
|
||||
|
||||
func cloneAdmin(value *Admin) *Admin {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
if value.JWT != nil {
|
||||
item := *value.JWT
|
||||
copy.JWT = &item
|
||||
copied := *value
|
||||
copied.JWT = clonePtr(value.JWT)
|
||||
copied.Captcha = clonePtr(value.Captcha)
|
||||
copied.Local = clonePtr(value.Local)
|
||||
copied.Email = clonePtr(value.Email)
|
||||
copied.Media = clonePtr(value.Media)
|
||||
copied.System = clonePtr(value.System)
|
||||
copied.App = clonePtr(value.App)
|
||||
copied.DiskList = cloneSlice(value.DiskList, clonePtr)
|
||||
copied.Storage = cloneStorage(value.Storage)
|
||||
if copied.Zap = clonePtr(value.Zap); copied.Zap != nil {
|
||||
copied.Zap.FileOnlyModules = slices.Clone(value.Zap.FileOnlyModules)
|
||||
}
|
||||
if value.Captcha != nil {
|
||||
item := *value.Captcha
|
||||
copy.Captcha = &item
|
||||
if copied.CORS = clonePtr(value.CORS); copied.CORS != nil {
|
||||
copied.CORS.Whitelist = cloneSlice(value.CORS.Whitelist, clonePtr)
|
||||
}
|
||||
if value.Local != nil {
|
||||
item := *value.Local
|
||||
copy.Local = &item
|
||||
}
|
||||
if value.Email != nil {
|
||||
item := *value.Email
|
||||
copy.Email = &item
|
||||
}
|
||||
if value.Media != nil {
|
||||
item := *value.Media
|
||||
copy.Media = &item
|
||||
}
|
||||
if value.System != nil {
|
||||
item := *value.System
|
||||
copy.System = &item
|
||||
}
|
||||
if value.Zap != nil {
|
||||
item := *value.Zap
|
||||
item.FileOnlyModules = append([]string(nil), value.Zap.FileOnlyModules...)
|
||||
copy.Zap = &item
|
||||
}
|
||||
if value.App != nil {
|
||||
item := *value.App
|
||||
copy.App = &item
|
||||
}
|
||||
if value.CORS != nil {
|
||||
item := *value.CORS
|
||||
item.Whitelist = make([]*CORSRule, len(value.CORS.Whitelist))
|
||||
for i, rule := range value.CORS.Whitelist {
|
||||
if rule != nil {
|
||||
next := *rule
|
||||
item.Whitelist[i] = &next
|
||||
}
|
||||
}
|
||||
copy.CORS = &item
|
||||
}
|
||||
if value.Storage != nil {
|
||||
copy.Storage = cloneStorage(value.Storage)
|
||||
}
|
||||
copy.DiskList = make([]*Disk, len(value.DiskList))
|
||||
for i, item := range value.DiskList {
|
||||
if item != nil {
|
||||
next := *item
|
||||
copy.DiskList[i] = &next
|
||||
}
|
||||
}
|
||||
return ©
|
||||
return &copied
|
||||
}
|
||||
|
||||
func cloneStorage(value *Storage) *Storage {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
if value.Qiniu != nil {
|
||||
item := *value.Qiniu
|
||||
copy.Qiniu = &item
|
||||
}
|
||||
copy.AliyunOSS = cloneObjectStore(value.AliyunOSS)
|
||||
copy.HuaweiOBS = cloneObjectStore(value.HuaweiOBS)
|
||||
copy.TencentCOS = cloneObjectStore(value.TencentCOS)
|
||||
copy.AWSS3 = cloneObjectStore(value.AWSS3)
|
||||
copy.CloudflareR2 = cloneObjectStore(value.CloudflareR2)
|
||||
copy.Minio = cloneObjectStore(value.Minio)
|
||||
return ©
|
||||
}
|
||||
func cloneObjectStore(value *ObjectStore) *ObjectStore {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
copied := *value
|
||||
copied.Qiniu = clonePtr(value.Qiniu)
|
||||
copied.AliyunOSS = clonePtr(value.AliyunOSS)
|
||||
copied.HuaweiOBS = clonePtr(value.HuaweiOBS)
|
||||
copied.TencentCOS = clonePtr(value.TencentCOS)
|
||||
copied.AWSS3 = clonePtr(value.AWSS3)
|
||||
copied.CloudflareR2 = clonePtr(value.CloudflareR2)
|
||||
copied.Minio = clonePtr(value.Minio)
|
||||
return &copied
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func cloneAdminConfig(value *config.Admin) *config.Admin {
|
||||
if value == nil {
|
||||
return &config.Admin{}
|
||||
}
|
||||
return config.CloneAdmin(value)
|
||||
}
|
||||
+53
-235
@@ -3,144 +3,24 @@ package data
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"kra/internal/config"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
"kra/internal/integration/storage"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func configMap(input any) (map[string]any, error) {
|
||||
raw, err := yaml.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var output map[string]any
|
||||
if err = yaml.Unmarshal(raw, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
// Writing config.yaml goes through config.Document, which owns the file format
|
||||
// for both directions. This file only decides which sections to write.
|
||||
|
||||
func setYAMLMapping(node *yaml.Node, key string, value any) error {
|
||||
if node.Kind == yaml.DocumentNode {
|
||||
node = node.Content[0]
|
||||
// cloneAdminConfig copies an admin section, normalizing nil to an empty value so
|
||||
// callers can edit the result without a nil check.
|
||||
func cloneAdminConfig(value *config.Admin) *config.Admin {
|
||||
if value == nil {
|
||||
return &config.Admin{}
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("configuration root is not a mapping")
|
||||
}
|
||||
raw, err := yaml.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var replacement yaml.Node
|
||||
if err = yaml.Unmarshal(raw, &replacement); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
mergeYAMLNode(node.Content[i+1], replacement.Content[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, replacement.Content[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeYAMLNode updates values produced from the runtime configuration while
|
||||
// retaining keys that are not represented by the typed configuration and
|
||||
// comments that were already present in the user's config file.
|
||||
func mergeYAMLNode(dst, src *yaml.Node) {
|
||||
if dst.Kind == yaml.MappingNode && src.Kind == yaml.MappingNode {
|
||||
for i := 0; i+1 < len(src.Content); i += 2 {
|
||||
key := src.Content[i].Value
|
||||
found := false
|
||||
for j := 0; j+1 < len(dst.Content); j += 2 {
|
||||
if dst.Content[j].Value == key {
|
||||
mergeYAMLNode(dst.Content[j+1], src.Content[i+1])
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
dst.Content = append(dst.Content, src.Content[i], src.Content[i+1])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Keep comments attached to a scalar/sequence node when its value changes.
|
||||
head, line, foot := dst.HeadComment, dst.LineComment, dst.FootComment
|
||||
*dst = *src
|
||||
dst.HeadComment, dst.LineComment, dst.FootComment = head, line, foot
|
||||
}
|
||||
|
||||
func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
node = node.Content[0]
|
||||
}
|
||||
for _, key := range keys {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
var next *yaml.Node
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
next = node.Content[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
node = next
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func deleteYAMLMapping(node *yaml.Node, keys ...string) {
|
||||
if len(keys) == 0 || node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
node = node.Content[0]
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return
|
||||
}
|
||||
key := keys[0]
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value != key {
|
||||
continue
|
||||
}
|
||||
if len(keys) == 1 {
|
||||
node.Content = append(node.Content[:i], node.Content[i+2:]...)
|
||||
return
|
||||
}
|
||||
deleteYAMLMapping(node.Content[i+1], keys[1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func setServerHTTPPort(document *yaml.Node, port int) error {
|
||||
if port <= 0 {
|
||||
return nil
|
||||
}
|
||||
host := "0.0.0.0"
|
||||
if addr := yamlMappingValue(document, "server", "http", "addr"); addr != nil {
|
||||
if currentHost, _, err := net.SplitHostPort(addr.Value); err == nil && currentHost != "" {
|
||||
host = currentHost
|
||||
}
|
||||
}
|
||||
return setYAMLMapping(document, "server", map[string]any{"http": map[string]any{"addr": net.JoinHostPort(host, strconv.Itoa(int(port)))}})
|
||||
return config.CloneAdmin(value)
|
||||
}
|
||||
|
||||
func (d *Data) persistConfig() error {
|
||||
@@ -158,40 +38,28 @@ func (d *Data) persistConfigValuesLocked(dataConfig *config.Data, adminConfig *c
|
||||
if adminConfig == nil || adminConfig.ConfigPath == "" {
|
||||
return nil
|
||||
}
|
||||
configPath := adminConfig.ConfigPath
|
||||
raw, err := os.ReadFile(configPath)
|
||||
document, err := config.OpenDocument(adminConfig.ConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var document yaml.Node
|
||||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||
return err
|
||||
}
|
||||
dataValue, err := configMap(dataConfig)
|
||||
if err != nil {
|
||||
if err = document.Set("data", dataConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
// Storage and email live in the database; the file must not shadow them.
|
||||
fileAdmin := cloneAdminConfig(adminConfig)
|
||||
fileAdmin.Storage = nil
|
||||
fileAdmin.Email = nil
|
||||
adminValue, err := configMap(fileAdmin)
|
||||
if err != nil {
|
||||
if err = document.Set("admin", fileAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = setYAMLMapping(&document, "data", dataValue); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = setYAMLMapping(&document, "admin", adminValue); err != nil {
|
||||
return err
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
document.Delete("admin", "storage")
|
||||
document.Delete("admin", "email")
|
||||
if adminConfig.System != nil {
|
||||
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
|
||||
if err = document.SetServerHTTPPort(adminConfig.System.Addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeConfigDocument(configPath, &document)
|
||||
return document.Save()
|
||||
}
|
||||
|
||||
// persistDatabaseConfig writes only the database selected on the init page and
|
||||
@@ -207,14 +75,10 @@ func (d *Data) persistDatabaseConfig(database *config.Database, signingKey strin
|
||||
if configPath == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(configPath)
|
||||
document, err := config.OpenDocument(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var document yaml.Node
|
||||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||
return err
|
||||
}
|
||||
source, err := databaseDSN(database, "")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -232,17 +96,17 @@ func (d *Data) persistDatabaseConfig(database *config.Database, signingKey strin
|
||||
"config": database.Config,
|
||||
"path": database.Path,
|
||||
}
|
||||
if err = setYAMLMapping(&document, "data", map[string]any{"database": databaseValue}); err != nil {
|
||||
if err = document.Set("data", map[string]any{"database": databaseValue}); err != nil {
|
||||
return err
|
||||
}
|
||||
if signingKey != "" {
|
||||
if err = setYAMLMapping(&document, "admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil {
|
||||
if err = document.Set("admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
return writeConfigDocument(configPath, &document)
|
||||
document.Delete("admin", "storage")
|
||||
document.Delete("admin", "email")
|
||||
return document.Save()
|
||||
}
|
||||
|
||||
func (d *Data) removeIntegrationConfigFromFile() error {
|
||||
@@ -252,48 +116,16 @@ func (d *Data) removeIntegrationConfigFromFile() error {
|
||||
if configPath == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(configPath)
|
||||
document, err := config.OpenDocument(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var document yaml.Node
|
||||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||
return err
|
||||
}
|
||||
if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil {
|
||||
if !document.Has("admin", "storage") && !document.Has("admin", "email") {
|
||||
return nil
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
return writeConfigDocument(configPath, &document)
|
||||
}
|
||||
|
||||
func writeConfigDocument(configPath string, document *yaml.Node) error {
|
||||
output, err := yaml.Marshal(document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(configPath), ".kra-config-*.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempName := temporary.Name()
|
||||
defer os.Remove(tempName)
|
||||
if _, err = temporary.Write(output); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err = temporary.Chmod(0o600); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err = temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tempName, configPath)
|
||||
document.Delete("admin", "storage")
|
||||
document.Delete("admin", "email")
|
||||
return document.Save()
|
||||
}
|
||||
|
||||
func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
@@ -311,6 +143,11 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
return fmt.Errorf("data.database and admin configuration are required")
|
||||
}
|
||||
next.Admin.ConfigPath = configPath
|
||||
// Every client below is opened before anything is published, so a failure at
|
||||
// any step leaves the running process untouched. undo closes whatever this
|
||||
// call opened; commit at the end hands all of it over instead.
|
||||
var undo rollback
|
||||
defer undo.run()
|
||||
databaseReady := databaseConnectionConfigured(next.Data.Database)
|
||||
var candidateDB *gorm.DB
|
||||
if databaseReady {
|
||||
@@ -324,14 +161,7 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
return fmt.Errorf("reload bootstrap database: %w", err)
|
||||
}
|
||||
}
|
||||
closeCandidate := true
|
||||
defer func() {
|
||||
if closeCandidate {
|
||||
if sqlDB, closeErr := candidateDB.DB(); closeErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
undo.add(func() { closeGormDB(candidateDB) })
|
||||
if databaseReady {
|
||||
if sqlDB, dbErr := candidateDB.DB(); dbErr != nil {
|
||||
return dbErr
|
||||
@@ -355,12 +185,12 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
if legacyEmail == nil && currentAdmin != nil {
|
||||
legacyEmail = currentAdmin.Email
|
||||
}
|
||||
storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage)
|
||||
storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), legacyStorage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload storage configuration: %w", err)
|
||||
}
|
||||
next.Admin.Storage = storageConfig
|
||||
emailConfig, err := resolveEmailIntegrationConfig(candidateDB.WithContext(ctx), legacyEmail)
|
||||
emailConfig, err := dataintegration.ResolveEmailConfig(candidateDB.WithContext(ctx), legacyEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload email configuration: %w", err)
|
||||
}
|
||||
@@ -371,47 +201,37 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
}
|
||||
useRedis := next.Admin.System != nil && next.Admin.System.UseRedis
|
||||
candidateRedis := openRedis(next.Data.Redis, useRedis, d.logger())
|
||||
candidateRedisAccepted := false
|
||||
defer func() {
|
||||
if !candidateRedisAccepted && candidateRedis != nil {
|
||||
_ = candidateRedis.Close()
|
||||
undo.add(func() {
|
||||
if candidateRedis != nil {
|
||||
closeRedisClient(candidateRedis)
|
||||
}
|
||||
}()
|
||||
})
|
||||
useRedisList := useRedis && next.Admin.System.UseMultipoint
|
||||
candidateRedisList := openRedisList(next.Data.RedisList, useRedisList, d.logger())
|
||||
candidateRedisListAccepted := false
|
||||
defer func() {
|
||||
if !candidateRedisListAccepted {
|
||||
closeRedisList(candidateRedisList)
|
||||
}
|
||||
}()
|
||||
undo.add(func() { closeRedisList(candidateRedisList) })
|
||||
useMongo := next.Admin.System != nil && next.Admin.System.UseMongo
|
||||
// openMongo always reports a nil client alongside its error, so an unreachable
|
||||
// Mongo only warns: it must not fail an otherwise valid reload.
|
||||
candidateMongo, mongoErr := openMongo(next.Data.Mongo, useMongo)
|
||||
if mongoErr != nil {
|
||||
d.logger().Error("mongo unavailable during configuration reload", "mod", "mongo", "error", mongoErr)
|
||||
}
|
||||
mongoAccepted := false
|
||||
defer func() {
|
||||
if !mongoAccepted && candidateMongo != nil {
|
||||
_ = candidateMongo.Disconnect(context.Background())
|
||||
undo.add(func() {
|
||||
if candidateMongo != nil {
|
||||
closeMongoClient(candidateMongo)
|
||||
}
|
||||
}()
|
||||
})
|
||||
candidateDBList, err := openDatabaseList(next.Data.DatabaseList, d.logger())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidateDBListAccepted := false
|
||||
defer func() {
|
||||
if !candidateDBListAccepted {
|
||||
closeDatabaseList(candidateDBList)
|
||||
}
|
||||
}()
|
||||
integrationConfigs, err := readIntegrationRuntime(candidateDB.WithContext(ctx))
|
||||
undo.add(func() { closeDatabaseList(candidateDBList) })
|
||||
integrationConfigs, err := dataintegration.ReadRuntime(candidateDB.WithContext(ctx))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload integration runtime: %w", err)
|
||||
}
|
||||
|
||||
d.gormDB.replace(candidateDB, d.enqueueDataScopeAudit)
|
||||
d.replacePrimaryDB(candidateDB)
|
||||
d.databaseReady.Store(databaseReady)
|
||||
for _, item := range candidateDBList {
|
||||
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
|
||||
@@ -431,14 +251,12 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
} else {
|
||||
d.logger().Warn("keeping the previous redis list because the reloaded configuration failed to connect", "mod", "redis")
|
||||
}
|
||||
// A nil client here means Mongo is switched off in the reloaded configuration,
|
||||
// which must still retire the running client; only an outright failure keeps it.
|
||||
if mongoErr == nil {
|
||||
d.mongo.replace(candidateMongo)
|
||||
mongoAccepted = true
|
||||
}
|
||||
closeCandidate = false
|
||||
candidateDBListAccepted = true
|
||||
candidateRedisAccepted = true
|
||||
candidateRedisListAccepted = true
|
||||
undo.commit()
|
||||
d.runtime.Replace(next)
|
||||
if d.integrations != nil {
|
||||
d.integrations.Replace(integrationConfigs)
|
||||
|
||||
+38
-10
@@ -19,6 +19,7 @@ import (
|
||||
datatask "kra/internal/data/task"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
"kra/internal/integration/storage"
|
||||
"kra/pkg/database/migration"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
@@ -47,11 +48,11 @@ type Data struct {
|
||||
initMu sync.Mutex
|
||||
configMu sync.Mutex
|
||||
databaseReady atomic.Bool
|
||||
gormDB *reloadableDB
|
||||
redis *reloadableRedis
|
||||
gormDB *reloadable[*gorm.DB]
|
||||
redis *reloadable[redis.UniversalClient]
|
||||
redisListMu sync.RWMutex
|
||||
redisList map[string]redis.UniversalClient
|
||||
mongo *reloadableMongo
|
||||
mongo *reloadable[*mongo.Client]
|
||||
runtime *config.Store
|
||||
integrations *runtimeconfig.Store
|
||||
storage *storage.Reloadable
|
||||
@@ -71,7 +72,7 @@ func (d *Data) DB() *gorm.DB {
|
||||
if d == nil || d.gormDB == nil {
|
||||
return nil
|
||||
}
|
||||
return d.gormDB.DB()
|
||||
return d.gormDB.load()
|
||||
}
|
||||
|
||||
// DatabaseReady reports whether the configured primary database has been
|
||||
@@ -103,7 +104,7 @@ func (d *Data) IntegrationRuntime() *runtimeconfig.Store {
|
||||
// the system export module.
|
||||
func (d *Data) Database(name string) (*gorm.DB, error) {
|
||||
if name == "" {
|
||||
return d.gormDB.DB(), nil
|
||||
return d.gormDB.load(), nil
|
||||
}
|
||||
d.dbListMu.RLock()
|
||||
db := d.dbList[name]
|
||||
@@ -306,11 +307,11 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
|
||||
}
|
||||
}
|
||||
if !usingFallback {
|
||||
storageConfig, storageErr := resolveStorageIntegrationConfig(db, admin.Storage)
|
||||
storageConfig, storageErr := dataintegration.ResolveStorageConfig(db, admin.Storage)
|
||||
if storageErr != nil {
|
||||
return nil, nil, fmt.Errorf("load storage integration configuration: %w", storageErr)
|
||||
}
|
||||
emailConfig, emailErr := resolveEmailIntegrationConfig(db, admin.Email)
|
||||
emailConfig, emailErr := dataintegration.ResolveEmailConfig(db, admin.Email)
|
||||
if emailErr != nil {
|
||||
return nil, nil, fmt.Errorf("load email integration configuration: %w", emailErr)
|
||||
}
|
||||
@@ -337,7 +338,7 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
|
||||
return nil, nil, fmt.Errorf("storage manager is nil")
|
||||
}
|
||||
useRedis := admin != nil && admin.System != nil && admin.System.UseRedis
|
||||
d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger))
|
||||
d.redis = newReloadable(openRedis(c.Redis, useRedis, appLogger), closeRedisClient)
|
||||
useRedisList := useRedis && admin.System.UseMultipoint
|
||||
d.redisList = openRedisList(c.RedisList, useRedisList, appLogger)
|
||||
useMongo := admin != nil && admin.System != nil && admin.System.UseMongo
|
||||
@@ -346,7 +347,7 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
|
||||
appLogger.Error("mongo unavailable", "mod", "mongo", "error", err)
|
||||
mongoClient = nil
|
||||
}
|
||||
d.mongo = newReloadableMongo(mongoClient)
|
||||
d.mongo = newReloadable(mongoClient, closeMongoClient)
|
||||
initialized = true
|
||||
return d, cleanup, nil
|
||||
}
|
||||
@@ -437,8 +438,35 @@ func (d *Data) replaceRedisList(clients map[string]redis.UniversalClient) {
|
||||
}
|
||||
}
|
||||
|
||||
// replacePrimaryDB swaps in a hot-reloaded pool, attaching the row-level data
|
||||
// scope callbacks before the handle becomes readable.
|
||||
func (d *Data) replacePrimaryDB(db *gorm.DB) {
|
||||
registerDataScopeCallbacks(db, d.enqueueDataScopeAudit)
|
||||
d.gormDB.replace(db)
|
||||
}
|
||||
|
||||
func (d *Data) activateDatabase(db *gorm.DB, config *config.Database) {
|
||||
d.gormDB.replace(db, d.enqueueDataScopeAudit)
|
||||
d.replacePrimaryDB(db)
|
||||
d.runtime.UpdateDatabase(config)
|
||||
d.databaseReady.Store(true)
|
||||
}
|
||||
|
||||
// migrateAll is the single data-layer migration entry point. Every module
|
||||
// must register its migrations through the application catalog; there is no
|
||||
// hidden system/payment fallback that could silently omit a new module.
|
||||
func migrateAll(db *gorm.DB, catalog module.Catalog) error {
|
||||
return migration.Run(db, catalog.MigrationSteps())
|
||||
}
|
||||
|
||||
// loadIntegrationRuntime republishes the database-backed integration
|
||||
// configuration into the live store the long-lived adapters read.
|
||||
func (d *Data) loadIntegrationRuntime(db *gorm.DB) error {
|
||||
configs, err := dataintegration.ReadRuntime(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.integrations != nil {
|
||||
d.integrations.Replace(configs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func (w *dataScopeAuditWriter) run() {
|
||||
if len(batch) == 0 || w.data == nil || w.data.gormDB == nil {
|
||||
return
|
||||
}
|
||||
db := w.data.gormDB.DB()
|
||||
db := w.data.gormDB.load()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestDataScopeAuditWriterUsesReloadedDatabase(t *testing.T) {
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
d.enqueueDataScopeAudit(dataAccessLogPO{EventType: "no_identity", TargetTable: "example"})
|
||||
d.gormDB.replace(second, nil)
|
||||
d.gormDB.replace(second)
|
||||
waitForDataScopeAuditCount(t, second, 1)
|
||||
|
||||
var firstCount int64
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"kra/internal/biz/system"
|
||||
configpkg "kra/internal/config"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
"kra/internal/integration/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -172,7 +173,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||
if currentAdmin != nil {
|
||||
legacyStorage = currentAdmin.Storage
|
||||
}
|
||||
storageConfig, err := resolveStorageIntegrationConfig(candidate.WithContext(ctx), legacyStorage)
|
||||
storageConfig, err := dataintegration.ResolveStorageConfig(candidate.WithContext(ctx), legacyStorage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize storage integration configuration: %w", err)
|
||||
}
|
||||
@@ -180,7 +181,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||
if currentAdmin != nil {
|
||||
legacyEmail = currentAdmin.Email
|
||||
}
|
||||
emailConfig, err := resolveEmailIntegrationConfig(candidate.WithContext(ctx), legacyEmail)
|
||||
emailConfig, err := dataintegration.ResolveEmailConfig(candidate.WithContext(ctx), legacyEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize email integration configuration: %w", err)
|
||||
}
|
||||
@@ -188,7 +189,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||
if err := d.persistDatabaseConfig(database, signingKey); err != nil {
|
||||
return fmt.Errorf("persist database configuration: %w", err)
|
||||
}
|
||||
integrationConfigs, err := readIntegrationRuntime(candidate)
|
||||
integrationConfigs, err := dataintegration.ReadRuntime(candidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize integration runtime: %w", err)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,73 @@ type ConfigPO struct {
|
||||
|
||||
func (ConfigPO) TableName() string { return "sys_integration_configs" }
|
||||
|
||||
// ConfigRow is one integration configuration row without its PO: the parent
|
||||
// data package owns the storage and email upgrade paths, but the table shape
|
||||
// stays private here.
|
||||
type ConfigRow struct {
|
||||
Provider string
|
||||
Enabled bool
|
||||
Config string
|
||||
}
|
||||
|
||||
// HasConfigTable reports whether the integration configuration table exists,
|
||||
// which callers use to tell "not migrated yet" from "no rows".
|
||||
func HasConfigTable(db *gorm.DB) bool {
|
||||
return db != nil && cleanSession(db).Migrator().HasTable(&ConfigPO{})
|
||||
}
|
||||
|
||||
// ListConfigs returns every row of a kind in insertion order.
|
||||
func ListConfigs(db *gorm.DB, kind string) ([]ConfigRow, error) {
|
||||
var rows []ConfigPO
|
||||
if err := cleanSession(db).Where("kind = ?", kind).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ConfigRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, ConfigRow{Provider: row.Provider, Enabled: row.Enabled, Config: row.Config})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FindConfig returns one row and whether it exists. A missing row is not an
|
||||
// error because every caller treats it as "fall back to the legacy value".
|
||||
func FindConfig(db *gorm.DB, kind, provider string) (ConfigRow, bool, error) {
|
||||
var row ConfigPO
|
||||
err := cleanSession(db).Where("kind = ? AND provider = ?", kind, provider).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ConfigRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ConfigRow{}, false, err
|
||||
}
|
||||
return ConfigRow{Provider: row.Provider, Enabled: row.Enabled, Config: row.Config}, true, nil
|
||||
}
|
||||
|
||||
// UpsertConfigs writes every given row of a kind in one transaction, inserting
|
||||
// the ones that do not exist yet and updating the rest in place.
|
||||
func UpsertConfigs(db *gorm.DB, kind string, rows []ConfigRow) error {
|
||||
return cleanSession(db).Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range rows {
|
||||
var current ConfigPO
|
||||
err := tx.Where("kind = ? AND provider = ?", kind, item.Provider).First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
err = tx.Create(&ConfigPO{Kind: kind, Provider: item.Provider, Enabled: item.Enabled, Config: item.Config}).Error
|
||||
case err == nil:
|
||||
err = tx.Model(¤t).Updates(map[string]any{"enabled": item.Enabled, "config": item.Config}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// cleanSession drops conditions inherited from the caller's handle so a scoped
|
||||
// query cannot leak a WHERE clause into these statements.
|
||||
func cleanSession(db *gorm.DB) *gorm.DB { return db.Session(&gorm.Session{NewDB: true}) }
|
||||
|
||||
type integrationConfigRepo struct{ data Provider }
|
||||
|
||||
type paymentConfigReader struct{ data Provider }
|
||||
|
||||
@@ -29,7 +29,7 @@ func ensureCommunicationIntegrationConfigs(db *gorm.DB) error {
|
||||
for _, item := range defaults {
|
||||
var row ConfigPO
|
||||
err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error
|
||||
if gorm.ErrRecordNotFound == err {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
values, marshalErr := json.Marshal(integrationbiz.DefaultIntegrationConfig(item.kind, item.provider))
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kra/internal/config"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
kindStorage = "storage"
|
||||
kindEmail = "email"
|
||||
providerSMTP = "smtp"
|
||||
)
|
||||
|
||||
var storageProviders = []string{
|
||||
"local", "qiniu", "aliyun-oss", "huawei-obs", "tencent-cos", "aws-s3", "cloudflare-r2", "minio",
|
||||
}
|
||||
|
||||
func normalizeStorageType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return "local"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func storageProviderValue(storage *config.Storage, provider string) any {
|
||||
if storage == nil {
|
||||
storage = &config.Storage{}
|
||||
}
|
||||
switch provider {
|
||||
case "qiniu":
|
||||
if storage.Qiniu == nil {
|
||||
storage.Qiniu = &config.Qiniu{}
|
||||
}
|
||||
return storage.Qiniu
|
||||
case "aliyun-oss":
|
||||
return ensureObjectStore(&storage.AliyunOSS)
|
||||
case "huawei-obs":
|
||||
return ensureObjectStore(&storage.HuaweiOBS)
|
||||
case "tencent-cos":
|
||||
return ensureObjectStore(&storage.TencentCOS)
|
||||
case "aws-s3":
|
||||
return ensureObjectStore(&storage.AWSS3)
|
||||
case "cloudflare-r2":
|
||||
return ensureObjectStore(&storage.CloudflareR2)
|
||||
case "minio":
|
||||
return ensureObjectStore(&storage.Minio)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ensureObjectStore(value **config.ObjectStore) *config.ObjectStore {
|
||||
if *value == nil {
|
||||
*value = &config.ObjectStore{}
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func marshalStorageProvider(storage *config.Storage, provider string) (string, error) {
|
||||
value := storageProviderValue(storage, provider)
|
||||
if value == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func unmarshalStorageProvider(storage *config.Storage, provider, value string) error {
|
||||
if provider == "local" || strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
target := storageProviderValue(storage, provider)
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
if !json.Valid([]byte(value)) {
|
||||
return fmt.Errorf("invalid %s integration configuration", provider)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), target); err != nil {
|
||||
return fmt.Errorf("decode %s integration configuration: %w", provider, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SaveStorageConfig(db *gorm.DB, storage *config.Storage) error {
|
||||
if storage == nil {
|
||||
storage = &config.Storage{}
|
||||
}
|
||||
active := normalizeStorageType(storage.Type)
|
||||
known := false
|
||||
for _, provider := range storageProviders {
|
||||
if provider == active {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return fmt.Errorf("unsupported storage type %q", active)
|
||||
}
|
||||
|
||||
rows := make([]ConfigRow, 0, len(storageProviders))
|
||||
for _, provider := range storageProviders {
|
||||
value, err := marshalStorageProvider(storage, provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s integration configuration: %w", provider, err)
|
||||
}
|
||||
rows = append(rows, ConfigRow{Provider: provider, Enabled: provider == active, Config: value})
|
||||
}
|
||||
return UpsertConfigs(db, kindStorage, rows)
|
||||
}
|
||||
|
||||
func LoadStorageConfig(db *gorm.DB) (*config.Storage, bool, error) {
|
||||
rows, err := ListConfigs(db, kindStorage)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
storage := &config.Storage{Type: "local"}
|
||||
for _, row := range rows {
|
||||
if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if row.Enabled {
|
||||
storage.Type = row.Provider
|
||||
}
|
||||
}
|
||||
return storage, true, nil
|
||||
}
|
||||
|
||||
// ResolveStorageConfig imports a legacy YAML value only when the database has
|
||||
// no storage configuration yet. The database is authoritative afterwards.
|
||||
func ResolveStorageConfig(db *gorm.DB, legacy *config.Storage) (*config.Storage, error) {
|
||||
if !HasConfigTable(db) {
|
||||
if legacy == nil {
|
||||
return &config.Storage{Type: "local"}, nil
|
||||
}
|
||||
return config.CloneStorage(legacy), nil
|
||||
}
|
||||
storage, found, err := LoadStorageConfig(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return storage, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = &config.Storage{Type: "local"}
|
||||
}
|
||||
if err = SaveStorageConfig(db, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage, _, err = LoadStorageConfig(db)
|
||||
return storage, err
|
||||
}
|
||||
|
||||
func defaultEmailConfig() *config.Email { return &config.Email{Port: 465, IsSSL: true} }
|
||||
|
||||
func SaveEmailConfig(db *gorm.DB, email *config.Email) error {
|
||||
if email == nil {
|
||||
email = defaultEmailConfig()
|
||||
}
|
||||
raw, err := json.Marshal(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode smtp integration configuration: %w", err)
|
||||
}
|
||||
enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0
|
||||
return UpsertConfigs(db, kindEmail, []ConfigRow{{Provider: providerSMTP, Enabled: enabled, Config: string(raw)}})
|
||||
}
|
||||
|
||||
func LoadEmailConfig(db *gorm.DB) (*config.Email, bool, error) {
|
||||
row, found, err := FindConfig(db, kindEmail, providerSMTP)
|
||||
if err != nil || !found {
|
||||
return nil, false, err
|
||||
}
|
||||
if !json.Valid([]byte(row.Config)) {
|
||||
return nil, false, errors.New("invalid smtp integration configuration")
|
||||
}
|
||||
email := defaultEmailConfig()
|
||||
if err = json.Unmarshal([]byte(row.Config), email); err != nil {
|
||||
return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err)
|
||||
}
|
||||
return email, true, nil
|
||||
}
|
||||
|
||||
func ResolveEmailConfig(db *gorm.DB, legacy *config.Email) (*config.Email, error) {
|
||||
if !HasConfigTable(db) {
|
||||
if legacy == nil {
|
||||
return defaultEmailConfig(), nil
|
||||
}
|
||||
return config.CloneEmail(legacy), nil
|
||||
}
|
||||
email, found, err := LoadEmailConfig(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return email, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = defaultEmailConfig()
|
||||
}
|
||||
if err = SaveEmailConfig(db, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email, _, err = LoadEmailConfig(db)
|
||||
return email, err
|
||||
}
|
||||
@@ -2,292 +2,30 @@ package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kra/internal/config"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
integrationKindStorage = "storage"
|
||||
integrationKindEmail = "email"
|
||||
)
|
||||
|
||||
var storageProviderNames = []string{
|
||||
"local",
|
||||
"qiniu",
|
||||
"aliyun-oss",
|
||||
"huawei-obs",
|
||||
"tencent-cos",
|
||||
"aws-s3",
|
||||
"cloudflare-r2",
|
||||
"minio",
|
||||
}
|
||||
|
||||
func normalizeStorageType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return "local"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func storageProviderValue(storage *config.Storage, provider string) any {
|
||||
if storage == nil {
|
||||
storage = &config.Storage{}
|
||||
}
|
||||
switch provider {
|
||||
case "qiniu":
|
||||
if storage.Qiniu == nil {
|
||||
storage.Qiniu = &config.Qiniu{}
|
||||
}
|
||||
return storage.Qiniu
|
||||
case "aliyun-oss":
|
||||
return ensureObjectStore(&storage.AliyunOSS)
|
||||
case "huawei-obs":
|
||||
return ensureObjectStore(&storage.HuaweiOBS)
|
||||
case "tencent-cos":
|
||||
return ensureObjectStore(&storage.TencentCOS)
|
||||
case "aws-s3":
|
||||
return ensureObjectStore(&storage.AWSS3)
|
||||
case "cloudflare-r2":
|
||||
return ensureObjectStore(&storage.CloudflareR2)
|
||||
case "minio":
|
||||
return ensureObjectStore(&storage.Minio)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ensureObjectStore(value **config.ObjectStore) *config.ObjectStore {
|
||||
if *value == nil {
|
||||
*value = &config.ObjectStore{}
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func marshalStorageProvider(storage *config.Storage, provider string) (string, error) {
|
||||
value := storageProviderValue(storage, provider)
|
||||
if value == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func unmarshalStorageProvider(storage *config.Storage, provider, value string) error {
|
||||
if provider == "local" || strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
target := storageProviderValue(storage, provider)
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
if !json.Valid([]byte(value)) {
|
||||
return fmt.Errorf("invalid %s integration configuration", provider)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), target); err != nil {
|
||||
return fmt.Errorf("decode %s integration configuration: %w", provider, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveStorageIntegrationConfig(db *gorm.DB, storage *config.Storage) error {
|
||||
if storage == nil {
|
||||
storage = &config.Storage{}
|
||||
}
|
||||
active := normalizeStorageType(storage.Type)
|
||||
known := false
|
||||
for _, provider := range storageProviderNames {
|
||||
if provider == active {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return fmt.Errorf("unsupported storage type %q", active)
|
||||
}
|
||||
|
||||
return db.Session(&gorm.Session{NewDB: true}).Transaction(func(tx *gorm.DB) error {
|
||||
for _, provider := range storageProviderNames {
|
||||
value, err := marshalStorageProvider(storage, provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s integration configuration: %w", provider, err)
|
||||
}
|
||||
var current dataintegration.ConfigPO
|
||||
err = tx.Where("kind = ? AND provider = ?", integrationKindStorage, provider).First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
current = dataintegration.ConfigPO{Kind: integrationKindStorage, Provider: provider}
|
||||
current.Enabled, current.Config = provider == active, value
|
||||
if err = tx.Create(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
if err = tx.Model(¤t).Updates(map[string]any{"enabled": provider == active, "config": value}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func loadStorageIntegrationConfig(db *gorm.DB) (*config.Storage, bool, error) {
|
||||
var rows []dataintegration.ConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind = ?", integrationKindStorage).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
storage := &config.Storage{Type: "local"}
|
||||
for _, row := range rows {
|
||||
if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if row.Enabled {
|
||||
storage.Type = row.Provider
|
||||
}
|
||||
}
|
||||
return storage, true, nil
|
||||
}
|
||||
|
||||
// resolveStorageIntegrationConfig upgrades a legacy YAML configuration only
|
||||
// when the database has no storage rows yet. From then on the database is the
|
||||
// sole source of truth.
|
||||
func resolveStorageIntegrationConfig(db *gorm.DB, legacy *config.Storage) (*config.Storage, error) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return &config.Storage{Type: "local"}, nil
|
||||
}
|
||||
return config.CloneStorage(legacy), nil
|
||||
}
|
||||
storage, found, err := loadStorageIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return storage, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = &config.Storage{Type: "local"}
|
||||
}
|
||||
if err = saveStorageIntegrationConfig(clean, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage, _, err = loadStorageIntegrationConfig(clean)
|
||||
return storage, err
|
||||
}
|
||||
|
||||
func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *config.Storage) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
db := d.gormDB.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&dataintegration.ConfigPO{}) {
|
||||
db := d.gormDB.load().WithContext(ctx)
|
||||
if !dataintegration.HasConfigTable(db) {
|
||||
return errors.New("integration configuration table does not exist")
|
||||
}
|
||||
return saveStorageIntegrationConfig(db, storage)
|
||||
}
|
||||
|
||||
func defaultEmailIntegrationConfig() *config.Email {
|
||||
return &config.Email{Port: 465, IsSSL: true}
|
||||
}
|
||||
|
||||
func saveEmailIntegrationConfig(db *gorm.DB, email *config.Email) error {
|
||||
if email == nil {
|
||||
email = defaultEmailIntegrationConfig()
|
||||
}
|
||||
raw, err := json.Marshal(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode smtp integration configuration: %w", err)
|
||||
}
|
||||
enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
var current dataintegration.ConfigPO
|
||||
err = clean.Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return clean.Create(&dataintegration.ConfigPO{
|
||||
Kind: integrationKindEmail, Provider: "smtp", Enabled: enabled, Config: string(raw),
|
||||
}).Error
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
return clean.Model(¤t).Updates(map[string]any{"enabled": enabled, "config": string(raw)}).Error
|
||||
}
|
||||
}
|
||||
|
||||
func loadEmailIntegrationConfig(db *gorm.DB) (*config.Email, bool, error) {
|
||||
var row dataintegration.ConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !json.Valid([]byte(row.Config)) {
|
||||
return nil, false, errors.New("invalid smtp integration configuration")
|
||||
}
|
||||
email := defaultEmailIntegrationConfig()
|
||||
if err = json.Unmarshal([]byte(row.Config), email); err != nil {
|
||||
return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err)
|
||||
}
|
||||
return email, true, nil
|
||||
}
|
||||
|
||||
func resolveEmailIntegrationConfig(db *gorm.DB, legacy *config.Email) (*config.Email, error) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return defaultEmailIntegrationConfig(), nil
|
||||
}
|
||||
return config.CloneEmail(legacy), nil
|
||||
}
|
||||
email, found, err := loadEmailIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return email, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = defaultEmailIntegrationConfig()
|
||||
}
|
||||
if err = saveEmailIntegrationConfig(clean, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email, _, err = loadEmailIntegrationConfig(clean)
|
||||
return email, err
|
||||
return dataintegration.SaveStorageConfig(db, storage)
|
||||
}
|
||||
|
||||
func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *config.Email) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
db := d.gormDB.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&dataintegration.ConfigPO{}) {
|
||||
db := d.gormDB.load().WithContext(ctx)
|
||||
if !dataintegration.HasConfigTable(db) {
|
||||
return errors.New("integration configuration table does not exist")
|
||||
}
|
||||
return saveEmailIntegrationConfig(db, email)
|
||||
return dataintegration.SaveEmailConfig(db, email)
|
||||
}
|
||||
|
||||
@@ -64,21 +64,21 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
||||
},
|
||||
Minio: &config.ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true},
|
||||
}
|
||||
if err := saveStorageIntegrationConfig(db, storage); err != nil {
|
||||
if err := dataintegration.SaveStorageConfig(db, storage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
email := &config.Email{
|
||||
To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com",
|
||||
Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSSL: true,
|
||||
}
|
||||
if err := saveEmailIntegrationConfig(db, email); err != nil {
|
||||
if err := dataintegration.SaveEmailConfig(db, email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&dataintegration.ConfigPO{Kind: integrationbiz.IntegrationKindPayment, Provider: "wechat-pay", Config: `{"merchant_id":"123"}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, found, err := loadStorageIntegrationConfig(db)
|
||||
loaded, found, err := dataintegration.LoadStorageConfig(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
||||
t.Fatalf("qiniu configuration = %#v", loaded.Qiniu)
|
||||
}
|
||||
|
||||
loadedEmail, found, err := loadEmailIntegrationConfig(db)
|
||||
loadedEmail, found, err := dataintegration.LoadEmailConfig(db)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("email configuration found=%v, err=%v", found, err)
|
||||
}
|
||||
@@ -104,17 +104,17 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
var storageCount, emailCount, paymentCount int64
|
||||
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationKindStorage).Count(&storageCount).Error; err != nil {
|
||||
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", "storage").Count(&storageCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationbiz.IntegrationKindPayment).Count(&paymentCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationKindEmail).Count(&emailCount).Error; err != nil {
|
||||
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", "email").Count(&emailCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storageCount != int64(len(storageProviderNames)) {
|
||||
t.Fatalf("storage row count = %d, want %d", storageCount, len(storageProviderNames))
|
||||
if storageCount != 8 {
|
||||
t.Fatalf("storage row count = %d, want 8", storageCount)
|
||||
}
|
||||
if paymentCount != 1 {
|
||||
t.Fatalf("payment row count = %d, want 1", paymentCount)
|
||||
@@ -130,7 +130,7 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
Type: "qiniu",
|
||||
Qiniu: &config.Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"},
|
||||
}
|
||||
loaded, err := resolveStorageIntegrationConfig(db, legacy)
|
||||
loaded, err := dataintegration.ResolveStorageConfig(db, legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
Type: "minio",
|
||||
Minio: &config.ObjectStore{Bucket: "must-not-replace-database"},
|
||||
}
|
||||
loaded, err = resolveStorageIntegrationConfig(db, other)
|
||||
loaded, err = dataintegration.ResolveStorageConfig(db, other)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -169,14 +169,14 @@ func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) {
|
||||
func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
legacy := &config.Email{To: "ops@example.com", From: "old@example.com", Host: "smtp.old.example.com", Secret: "old-secret", Port: 465, IsSSL: true}
|
||||
loaded, err := resolveEmailIntegrationConfig(db, legacy)
|
||||
loaded, err := dataintegration.ResolveEmailConfig(db, legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret {
|
||||
t.Fatalf("migrated email = %#v", loaded)
|
||||
}
|
||||
loaded, err = resolveEmailIntegrationConfig(db, &config.Email{Host: "must-not-replace.example.com"})
|
||||
loaded, err = dataintegration.ResolveEmailConfig(db, &config.Email{Host: "must-not-replace.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -245,11 +245,9 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadableDB := &reloadableDB{}
|
||||
reloadableDB.current.Store(db)
|
||||
d := &Data{
|
||||
runtime: config.NewStore(&config.Config{Data: &config.Data{}, Admin: currentAdmin}),
|
||||
gormDB: reloadableDB,
|
||||
gormDB: newReloadableDB(db, nil),
|
||||
storage: currentStorage,
|
||||
}
|
||||
d.databaseReady.Store(true)
|
||||
@@ -274,11 +272,11 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
|
||||
if _, err = os.Stat(filepath.Join(newRoot, "active.txt")); err != nil {
|
||||
t.Fatalf("active storage did not write to the new root: %v", err)
|
||||
}
|
||||
loaded, found, err := loadStorageIntegrationConfig(db)
|
||||
loaded, found, err := dataintegration.LoadStorageConfig(db)
|
||||
if err != nil || !found || loaded.Type != "local" {
|
||||
t.Fatalf("database storage config = %#v, found=%v, err=%v", loaded, found, err)
|
||||
}
|
||||
loadedEmail, found, err := loadEmailIntegrationConfig(db)
|
||||
loadedEmail, found, err := dataintegration.LoadEmailConfig(db)
|
||||
if err != nil || !found || loadedEmail.Secret != "runtime-secret" {
|
||||
t.Fatalf("database email config = %#v, found=%v, err=%v", loadedEmail, found, err)
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
)
|
||||
|
||||
func readIntegrationRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) {
|
||||
return dataintegration.ReadRuntime(db)
|
||||
}
|
||||
|
||||
func (d *Data) loadIntegrationRuntime(db *gorm.DB) error {
|
||||
configs, err := readIntegrationRuntime(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.integrations != nil {
|
||||
d.integrations.Replace(configs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"kra/pkg/database/migration"
|
||||
"kra/pkg/module"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// migrateAll is the single data-layer migration entry point. Every module
|
||||
// must register its migrations through the application catalog; there is no
|
||||
// hidden system/payment fallback that could silently omit a new module.
|
||||
func migrateAll(db *gorm.DB, catalog module.Catalog) error {
|
||||
return migration.Run(db, catalog.MigrationSteps())
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package data
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -48,6 +47,21 @@ func (s *retiredSet[T]) take(item T) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// forget removes every pending retirement for item without closing it. A
|
||||
// reload can legitimately publish a previously retired handle again (for
|
||||
// example A -> B -> A); its old timers must no longer own that live handle.
|
||||
func (s *retiredSet[T]) forget(item T) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for index := 0; index < len(s.items); {
|
||||
if s.items[index] != item {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
s.items = append(s.items[:index], s.items[index+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *retiredSet[T]) drain() []T {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -56,138 +70,116 @@ func (s *retiredSet[T]) drain() []T {
|
||||
return items
|
||||
}
|
||||
|
||||
// reloadableDB makes the pointer swap atomic. Replaced pools stay open for
|
||||
// retireGrace so in-flight GORM operations remain valid.
|
||||
type reloadableDB struct {
|
||||
current atomic.Pointer[gorm.DB]
|
||||
retired retiredSet[*gorm.DB]
|
||||
// reloadable owns one hot-swappable storage client. The database, Redis and
|
||||
// Mongo handles all need the same three things — a racy-free read on every
|
||||
// request, an atomic swap on reload, and a close that also drains whatever the
|
||||
// grace period has not reaped yet — so they share this one implementation.
|
||||
type reloadable[T comparable] struct {
|
||||
mu sync.RWMutex
|
||||
current T
|
||||
retired retiredSet[T]
|
||||
closeOne func(T)
|
||||
}
|
||||
|
||||
func newReloadableDB(db *gorm.DB, enqueue dataScopeAuditEnqueue) *reloadableDB {
|
||||
r := &reloadableDB{}
|
||||
registerDataScopeCallbacks(db, enqueue)
|
||||
r.current.Store(db)
|
||||
return r
|
||||
func newReloadable[T comparable](current T, closeOne func(T)) *reloadable[T] {
|
||||
return &reloadable[T]{current: current, closeOne: closeOne}
|
||||
}
|
||||
|
||||
func (r *reloadableDB) WithContext(ctx context.Context) *gorm.DB {
|
||||
return r.current.Load().WithContext(ctx)
|
||||
// load returns the active client, or the zero value when the client was never
|
||||
// configured, so optional backends need no separate presence flag.
|
||||
func (r *reloadable[T]) load() T {
|
||||
var zero T
|
||||
if r == nil {
|
||||
return zero
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.current
|
||||
}
|
||||
|
||||
func (r *reloadableDB) DB() *gorm.DB { return r.current.Load() }
|
||||
|
||||
func (r *reloadableDB) replace(db *gorm.DB, enqueue dataScopeAuditEnqueue) {
|
||||
registerDataScopeCallbacks(db, enqueue)
|
||||
old := r.current.Swap(db)
|
||||
if old != nil && old != db {
|
||||
r.retired.retire(old, closeGormDB)
|
||||
// replace publishes next and retires the handle it displaced.
|
||||
func (r *reloadable[T]) replace(next T) {
|
||||
var zero T
|
||||
if next != zero {
|
||||
r.retired.forget(next)
|
||||
}
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = next
|
||||
r.mu.Unlock()
|
||||
if old != zero && old != next {
|
||||
r.retired.retire(old, func(item T) {
|
||||
// Serialize the final liveness check with replace: a handle that was
|
||||
// re-published while its timer fired must never be closed underneath
|
||||
// the new active configuration.
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.current != item {
|
||||
r.closeOne(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *reloadableDB) close() {
|
||||
all := append([]*gorm.DB{r.current.Load()}, r.retired.drain()...)
|
||||
seen := map[*gorm.DB]struct{}{}
|
||||
for _, db := range all {
|
||||
if db == nil {
|
||||
func (r *reloadable[T]) close() {
|
||||
var zero T
|
||||
r.mu.Lock()
|
||||
current := r.current
|
||||
r.current = zero
|
||||
r.mu.Unlock()
|
||||
seen := make(map[T]struct{})
|
||||
for _, item := range append([]T{current}, r.retired.drain()...) {
|
||||
if item == zero {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[db]; ok {
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[db] = struct{}{}
|
||||
closeGormDB(db)
|
||||
seen[item] = struct{}{}
|
||||
r.closeOne(item)
|
||||
}
|
||||
}
|
||||
|
||||
// rollback collects the cleanup for every client a multi-step reload opened
|
||||
// before it knows whether the reload will succeed. Any early return closes
|
||||
// exactly what was opened, in reverse order; commit hands every handle over to
|
||||
// the process and cancels all of it. One flag replaces one accepted-boolean plus
|
||||
// one deferred closure per client.
|
||||
type rollback struct {
|
||||
cleanups []func()
|
||||
committed bool
|
||||
}
|
||||
|
||||
func (r *rollback) add(cleanup func()) { r.cleanups = append(r.cleanups, cleanup) }
|
||||
|
||||
func (r *rollback) commit() { r.committed = true }
|
||||
|
||||
func (r *rollback) run() {
|
||||
if r.committed {
|
||||
return
|
||||
}
|
||||
for index := len(r.cleanups) - 1; index >= 0; index-- {
|
||||
r.cleanups[index]()
|
||||
}
|
||||
}
|
||||
|
||||
// newReloadableDB attaches the row-level data scope callbacks the pool needs
|
||||
// before it becomes readable. Data.replacePrimaryDB is the reload counterpart.
|
||||
func newReloadableDB(db *gorm.DB, enqueue dataScopeAuditEnqueue) *reloadable[*gorm.DB] {
|
||||
registerDataScopeCallbacks(db, enqueue)
|
||||
return newReloadable(db, closeGormDB)
|
||||
}
|
||||
|
||||
func closeGormDB(db *gorm.DB) {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type reloadableMongo struct {
|
||||
mu sync.RWMutex
|
||||
current *mongo.Client
|
||||
retired retiredSet[*mongo.Client]
|
||||
}
|
||||
|
||||
func newReloadableMongo(client *mongo.Client) *reloadableMongo {
|
||||
return &reloadableMongo{current: client}
|
||||
}
|
||||
|
||||
func (r *reloadableMongo) replace(client *mongo.Client) {
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = client
|
||||
r.mu.Unlock()
|
||||
if old != nil && old != client {
|
||||
r.retired.retire(old, closeMongoClient)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *reloadableMongo) load() *mongo.Client {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.current
|
||||
}
|
||||
|
||||
func (r *reloadableMongo) close() {
|
||||
r.mu.Lock()
|
||||
current := r.current
|
||||
r.current = nil
|
||||
r.mu.Unlock()
|
||||
for _, client := range append([]*mongo.Client{current}, r.retired.drain()...) {
|
||||
if client != nil {
|
||||
closeMongoClient(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closeMongoClient(client *mongo.Client) {
|
||||
_ = client.Disconnect(context.Background())
|
||||
}
|
||||
|
||||
type reloadableRedis struct {
|
||||
mu sync.RWMutex
|
||||
current redis.UniversalClient
|
||||
retired retiredSet[redis.UniversalClient]
|
||||
}
|
||||
|
||||
func newReloadableRedis(client redis.UniversalClient) *reloadableRedis {
|
||||
return &reloadableRedis{current: client}
|
||||
}
|
||||
|
||||
func (r *reloadableRedis) load() redis.UniversalClient {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.current
|
||||
}
|
||||
|
||||
func (r *reloadableRedis) replace(client redis.UniversalClient) {
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = client
|
||||
r.mu.Unlock()
|
||||
if old != nil && old != client {
|
||||
r.retired.retire(old, closeRedisClient)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *reloadableRedis) close() {
|
||||
r.mu.Lock()
|
||||
current := r.current
|
||||
r.current = nil
|
||||
r.mu.Unlock()
|
||||
for _, client := range append([]redis.UniversalClient{current}, r.retired.drain()...) {
|
||||
if client != nil {
|
||||
closeRedisClient(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closeRedisClient(client redis.UniversalClient) {
|
||||
_ = client.Close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package data
|
||||
|
||||
import "testing"
|
||||
|
||||
type reloadableTestClient struct{ id int }
|
||||
|
||||
func TestReloadableReclaimsRepublishedClient(t *testing.T) {
|
||||
first := &reloadableTestClient{id: 1}
|
||||
second := &reloadableTestClient{id: 2}
|
||||
closed := map[*reloadableTestClient]int{}
|
||||
clients := newReloadable(first, func(client *reloadableTestClient) { closed[client]++ })
|
||||
|
||||
clients.replace(second)
|
||||
clients.replace(first)
|
||||
|
||||
if clients.retired.take(first) {
|
||||
t.Fatal("republished client remained scheduled for retirement")
|
||||
}
|
||||
clients.close()
|
||||
if closed[first] != 1 {
|
||||
t.Fatalf("active client closed %d times, want 1", closed[first])
|
||||
}
|
||||
if closed[second] != 1 {
|
||||
t.Fatalf("retired client closed %d times, want 1", closed[second])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadableCloseDeduplicatesHandles(t *testing.T) {
|
||||
client := &reloadableTestClient{id: 1}
|
||||
closed := 0
|
||||
clients := newReloadable(client, func(*reloadableTestClient) { closed++ })
|
||||
clients.retired.items = append(clients.retired.items, client, client)
|
||||
|
||||
clients.close()
|
||||
if closed != 1 {
|
||||
t.Fatalf("client closed %d times, want 1", closed)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package initialize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/config"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Backend is the infrastructure boundary required by application
|
||||
// initialization and runtime configuration management.
|
||||
type Backend interface {
|
||||
IsInitialized(context.Context) (bool, error)
|
||||
InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error
|
||||
PersistConfig(context.Context) error
|
||||
PersistRuntimeConfig(context.Context, *config.Config) error
|
||||
ReloadConfig(context.Context) error
|
||||
Config() *config.Config
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"kra/internal/config"
|
||||
)
|
||||
@@ -16,7 +18,7 @@ type configurationEnvelope struct {
|
||||
}
|
||||
|
||||
func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
|
||||
current := r.backend.Config()
|
||||
current := r.Config()
|
||||
if current == nil {
|
||||
current = &config.Config{}
|
||||
}
|
||||
@@ -26,7 +28,7 @@ func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error {
|
||||
current := r.backend.Config()
|
||||
current := r.Config()
|
||||
if current == nil {
|
||||
current = &config.Config{}
|
||||
}
|
||||
@@ -56,14 +58,13 @@ func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) e
|
||||
if next.Admin != nil && current.Admin != nil {
|
||||
next.Admin.ConfigPath = current.Admin.ConfigPath
|
||||
}
|
||||
if err := refreshDatabaseSources(next.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
// The data backend rebuilds every DSN from the structured fields while
|
||||
// preserving a standalone Source, so no pre-clearing is needed here.
|
||||
return r.PersistRuntimeConfig(ctx, next)
|
||||
}
|
||||
|
||||
func (r *Repo) DiskMountPoints() []string {
|
||||
current := r.backend.Config()
|
||||
current := r.Config()
|
||||
if current == nil || current.Admin == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -85,74 +86,45 @@ func mergeJSON(raw json.RawMessage, target any) error {
|
||||
|
||||
func mergeDataJSON(raw json.RawMessage, target **config.Data) error {
|
||||
return mergeJSONMap(raw, target, func(values map[string]any) error {
|
||||
if redis := jsonObject(values["redis"]); redis != nil {
|
||||
if err := normalizeDuration(redis, "read_timeout", "readTimeout"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeDuration(redis, "write_timeout", "writeTimeout"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeRedisDurations(jsonObject(values["redis"])); err != nil {
|
||||
return err
|
||||
}
|
||||
if items, ok := values["redis_list"].([]any); ok {
|
||||
for _, item := range items {
|
||||
redis := jsonObject(item)
|
||||
if err := normalizeDuration(redis, "read_timeout", "readTimeout"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeDuration(redis, "write_timeout", "writeTimeout"); err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := values["redis_list"].([]any)
|
||||
for _, item := range items {
|
||||
if err := normalizeRedisDurations(jsonObject(item)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeRedisDurations(values map[string]any) error {
|
||||
if err := normalizeDuration(values, "read_timeout"); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeDuration(values, "write_timeout")
|
||||
}
|
||||
|
||||
// The admin page posts the camelCase shape managementConfig produced, so only
|
||||
// the duration strings still need converting once mergeJSONMap has restored the
|
||||
// snake_case key names the configuration structs declare.
|
||||
func mergeAdminJSON(raw json.RawMessage, target **config.Admin) error {
|
||||
return mergeJSONMap(raw, target, func(values map[string]any) error {
|
||||
moveJSONKey(values, "routerPrefix", "router_prefix")
|
||||
if jwt := jsonObject(values["jwt"]); jwt != nil {
|
||||
moveJSONKey(jwt, "signingKey", "signing_key")
|
||||
moveJSONKey(jwt, "expiresTime", "expires_time")
|
||||
moveJSONKey(jwt, "bufferTime", "buffer_time")
|
||||
if err := normalizeDuration(jwt, "expires_time", "expiresTime"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeDuration(jwt, "buffer_time", "bufferTime"); err != nil {
|
||||
return err
|
||||
}
|
||||
jwt := jsonObject(values["jwt"])
|
||||
if err := normalizeDuration(jwt, "expires_time"); err != nil {
|
||||
return err
|
||||
}
|
||||
if captcha := jsonObject(values["captcha"]); captcha != nil {
|
||||
moveJSONKey(captcha, "keyLong", "key_long")
|
||||
moveJSONKey(captcha, "imgWidth", "img_width")
|
||||
moveJSONKey(captcha, "imgHeight", "img_height")
|
||||
moveJSONKey(captcha, "storeExpiration", "store_expiration")
|
||||
return normalizeDuration(captcha, "store_expiration", "storeExpiration")
|
||||
if err := normalizeDuration(jwt, "buffer_time"); err != nil {
|
||||
return err
|
||||
}
|
||||
if local := jsonObject(values["local"]); local != nil {
|
||||
moveJSONKey(local, "storePath", "store_path")
|
||||
moveJSONKey(local, "pathPrefix", "path_prefix")
|
||||
}
|
||||
if media := jsonObject(values["media"]); media != nil {
|
||||
moveJSONKey(media, "sessionTtl", "session_ttl")
|
||||
moveJSONKey(media, "maxFileSize", "max_file_size")
|
||||
moveJSONKey(media, "chunkDir", "chunk_dir")
|
||||
}
|
||||
if system := jsonObject(values["system"]); system != nil {
|
||||
moveJSONKey(system, "useRedis", "use_redis")
|
||||
moveJSONKey(system, "useMultipoint", "use_multipoint")
|
||||
moveJSONKey(system, "useStrictAuth", "use_strict_auth")
|
||||
moveJSONKey(system, "disableAutoMigrate", "disable_auto_migrate")
|
||||
moveJSONKey(system, "useMongo", "use_mongo")
|
||||
moveJSONKey(system, "iplimitCount", "iplimit_count")
|
||||
moveJSONKey(system, "iplimitTime", "iplimit_time")
|
||||
}
|
||||
return nil
|
||||
return normalizeDuration(jsonObject(values["captcha"]), "store_expiration")
|
||||
})
|
||||
}
|
||||
|
||||
func mergeEmailJSON(raw json.RawMessage, target **config.Email) error {
|
||||
return mergeJSONMap(raw, target, func(values map[string]any) error {
|
||||
// Two legacy dashed keys the generic snake_case pass cannot derive.
|
||||
moveJSONKey(values, "is-ssl", "is_ssl")
|
||||
moveJSONKey(values, "is-loginauth", "is_login_auth")
|
||||
return nil
|
||||
@@ -167,6 +139,7 @@ func mergeJSONMap(raw json.RawMessage, target any, transform func(map[string]any
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
snakeCaseKeys(values)
|
||||
if transform != nil {
|
||||
if err := transform(values); err != nil {
|
||||
return err
|
||||
@@ -179,31 +152,62 @@ func mergeJSONMap(raw json.RawMessage, target any, transform func(map[string]any
|
||||
return mergeJSON(normalized, target)
|
||||
}
|
||||
|
||||
// snakeCaseKeys rewrites every camelCase key in the decoded payload to the
|
||||
// snake_case name the configuration structs declare. One recursive pass replaces
|
||||
// a hand-written rename list that had to grow with every new setting — and that
|
||||
// silently dropped whole sections when one of them was missed.
|
||||
func snakeCaseKeys(value any) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
renamed := map[string]any{}
|
||||
for key, item := range typed {
|
||||
snakeCaseKeys(item)
|
||||
if snake := snakeCase(key); snake != key {
|
||||
renamed[snake] = item
|
||||
delete(typed, key)
|
||||
}
|
||||
}
|
||||
for key, item := range renamed {
|
||||
typed[key] = item
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
snakeCaseKeys(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snakeCase(key string) string {
|
||||
var builder strings.Builder
|
||||
for index, symbol := range key {
|
||||
if !unicode.IsUpper(symbol) {
|
||||
builder.WriteRune(symbol)
|
||||
continue
|
||||
}
|
||||
if index > 0 {
|
||||
builder.WriteByte('_')
|
||||
}
|
||||
builder.WriteRune(unicode.ToLower(symbol))
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func jsonObject(value any) map[string]any {
|
||||
result, _ := value.(map[string]any)
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeDuration(values map[string]any, keys ...string) error {
|
||||
if values == nil {
|
||||
// A nil map reads as absent, so callers can pass jsonObject(...) straight in.
|
||||
func normalizeDuration(values map[string]any, key string) error {
|
||||
text, ok := values[key].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
value, err := time.ParseDuration(text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", text, err)
|
||||
}
|
||||
values[key] = int64(value)
|
||||
return nil
|
||||
value, err := time.ParseDuration(text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", text, err)
|
||||
}
|
||||
values[key] = int64(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -458,34 +462,3 @@ func preserveStorageSecrets(next, current *config.Storage) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDatabaseSources(value *config.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 *config.Database) error {
|
||||
if database == nil {
|
||||
return nil
|
||||
}
|
||||
hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || database.Password != "" || database.Name != "" || database.Config != "" || database.Path != ""
|
||||
if hasStructuredConfig {
|
||||
// Driver-specific DSN construction remains in data. Clearing Source
|
||||
// makes the data backend rebuild it from structured values, while a
|
||||
// standalone DSN is preserved when no structured fields are supplied.
|
||||
database.Source = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestConfigurationJSONMasksSecretsWithoutMutatingConfig(t *testing.T) {
|
||||
Data: &config.Data{Database: &config.Database{Password: "database-secret", Source: "dsn"}, Redis: &config.Redis{Password: "redis-secret"}},
|
||||
Admin: &config.Admin{JWT: &config.JWT{SigningKey: "jwt-secret", ExpiresTime: 24 * time.Hour}, Email: &config.Email{Secret: "email-secret"}, Storage: &config.Storage{Qiniu: &config.Qiniu{SecretKey: "storage-secret"}}},
|
||||
}}
|
||||
raw, err := (&Repo{backend: backend}).ConfigurationJSON()
|
||||
raw, err := (&Repo{Backend: backend}).ConfigurationJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestSaveConfigurationJSONMergesPartialValuesAndPreservesSecrets(t *testing.
|
||||
Admin: &config.Admin{ConfigPath: "config.yaml", JWT: &config.JWT{SigningKey: "jwt-secret", Issuer: "old", ExpiresTime: time.Hour}, Captcha: &config.Captcha{StoreExpiration: time.Minute}, Email: &config.Email{Host: "old.smtp", Secret: "email-secret", IsSSL: true}},
|
||||
}}
|
||||
raw := json.RawMessage(`{"data":{"database":{"host":"new","password":"******"},"redis":{"read_timeout":"250ms"}},"admin":{"jwt":{"issuer":"new","signingKey":"******","expiresTime":"48h"},"captcha":{"storeExpiration":"5m"}},"email":{"host":"new.smtp","secret":"******","is-ssl":false}}`)
|
||||
if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), raw); err != nil {
|
||||
if err := (&Repo{Backend: backend}).SaveConfigurationJSON(context.Background(), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := backend.persisted
|
||||
@@ -81,7 +81,7 @@ func TestSaveConfigurationJSONPreservesStandaloneDSN(t *testing.T) {
|
||||
Data: &config.Data{Database: &config.Database{Driver: "mysql", Source: "user:secret@tcp(database.example:3306)/kra"}},
|
||||
Admin: &config.Admin{},
|
||||
}}
|
||||
if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), json.RawMessage(`{"admin":{"routerPrefix":"/api"}}`)); err != nil {
|
||||
if err := (&Repo{Backend: backend}).SaveConfigurationJSON(context.Background(), json.RawMessage(`{"admin":{"routerPrefix":"/api"}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := backend.persisted.Data.Database.Source; got != "user:secret@tcp(database.example:3306)/kra" {
|
||||
|
||||
@@ -12,36 +12,36 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Backend is the infrastructure boundary required by application
|
||||
// initialization and runtime configuration management.
|
||||
type Backend interface {
|
||||
IsInitialized(context.Context) (bool, error)
|
||||
InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error
|
||||
PersistConfig(context.Context) error
|
||||
PersistRuntimeConfig(context.Context, *config.Config) error
|
||||
ReloadConfig(context.Context) error
|
||||
Config() *config.Config
|
||||
}
|
||||
|
||||
// Repo adapts Backend to system.InitializationRepo. Backend is embedded so the
|
||||
// four identically shaped methods are promoted instead of hand-forwarded; this
|
||||
// file adds only what the two boundaries genuinely disagree on.
|
||||
type Repo struct {
|
||||
backend Backend
|
||||
Backend
|
||||
catalog platformmodule.Catalog
|
||||
}
|
||||
|
||||
func NewRepo(backend Backend, catalog platformmodule.Catalog) system.InitializationRepo {
|
||||
return &Repo{backend: backend, catalog: catalog}
|
||||
}
|
||||
|
||||
func (r *Repo) IsInitialized(ctx context.Context) (bool, error) {
|
||||
return r.backend.IsInitialized(ctx)
|
||||
return &Repo{Backend: backend, catalog: catalog}
|
||||
}
|
||||
|
||||
// Initialize layers system and task seeding on top of the backend's database
|
||||
// lifecycle, which is the one place the two interfaces differ in shape.
|
||||
func (r *Repo) Initialize(ctx context.Context, input *system.DatabaseConfig) error {
|
||||
return r.backend.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error {
|
||||
return r.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error {
|
||||
if err := datasystem.SeedSystemWithCatalog(ctx, db, input, r.catalog); err != nil {
|
||||
return err
|
||||
}
|
||||
return datatask.SeedDefaults(ctx, db, r.catalog.DefaultTimedTasks())
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repo) PersistConfig(ctx context.Context) error {
|
||||
return r.backend.PersistConfig(ctx)
|
||||
}
|
||||
|
||||
func (r *Repo) PersistRuntimeConfig(ctx context.Context, value *config.Config) error {
|
||||
return r.backend.PersistRuntimeConfig(ctx, value)
|
||||
}
|
||||
|
||||
func (r *Repo) ReloadConfig(ctx context.Context) error {
|
||||
return r.backend.ReloadConfig(ctx)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,13 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
if got := catalog.MigrationSteps(); len(got) != 8 {
|
||||
t.Fatalf("module migrations = %d, want 8", len(got))
|
||||
}
|
||||
if surface := catalog.Surface(); len(surface.Menus) != 3 || len(surface.APIs) != 15 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", len(surface.Menus), len(surface.APIs))
|
||||
var menus, apis int
|
||||
for _, definition := range catalog.Definitions {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 3 || apis != 15 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -235,6 +235,62 @@ var routes = map[string]routeValue{
|
||||
"PUT /user/setUserInfo": {group: "系统用户", description: "设置用户信息", audit: true},
|
||||
}
|
||||
|
||||
// compiled is one catalog entry with its path pre-split into segments so that
|
||||
// per-request matching neither parses the map key nor allocates. wildcard marks
|
||||
// a Gin catch-all tail ("/*any"); that segment is dropped from parts because it
|
||||
// absorbs every remaining segment instead of matching one.
|
||||
type compiled struct {
|
||||
parts []string
|
||||
wildcard bool
|
||||
descriptor Descriptor
|
||||
}
|
||||
|
||||
// exact answers a canonical "METHOD /path" in a single map hit. byMethod holds
|
||||
// the same entries pre-split and bucketed by method, so the fallback needed for
|
||||
// path parameters and router prefixes scans one method's routes rather than the
|
||||
// whole catalog, and never re-splits a pattern.
|
||||
var (
|
||||
exact = make(map[string]Descriptor, len(routes))
|
||||
byMethod = make(map[string][]compiled)
|
||||
)
|
||||
|
||||
func init() {
|
||||
for key, value := range routes {
|
||||
descriptor := descriptorFrom(key, value)
|
||||
exact[descriptorKey(descriptor.Method, descriptor.Path)] = descriptor
|
||||
entry := compiled{parts: splitPath(descriptor.Path), descriptor: descriptor}
|
||||
if last := len(entry.parts) - 1; last >= 0 && strings.HasPrefix(entry.parts[last], "*") {
|
||||
entry.parts, entry.wildcard = entry.parts[:last], true
|
||||
}
|
||||
byMethod[descriptor.Method] = append(byMethod[descriptor.Method], entry)
|
||||
}
|
||||
// Suffix matching lets two patterns answer the same URL. Ordering the
|
||||
// buckets most-specific-first makes the winner deterministic instead of
|
||||
// dependent on map iteration order.
|
||||
for method := range byMethod {
|
||||
entries := byMethod[method]
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if len(entries[i].parts) != len(entries[j].parts) {
|
||||
return len(entries[i].parts) > len(entries[j].parts)
|
||||
}
|
||||
if entries[i].wildcard != entries[j].wildcard {
|
||||
return entries[j].wildcard
|
||||
}
|
||||
return literalCount(entries[i].parts) > literalCount(entries[j].parts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func literalCount(parts []string) int {
|
||||
count := 0
|
||||
for _, part := range parts {
|
||||
if !strings.HasPrefix(part, ":") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func splitKey(key string) (string, string) {
|
||||
parts := strings.SplitN(key, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
@@ -267,20 +323,52 @@ func descriptorFrom(key string, value routeValue) Descriptor {
|
||||
}
|
||||
}
|
||||
|
||||
func pathMatches(pattern, path string) bool {
|
||||
patternParts := strings.Split(strings.Trim(normalizePath(pattern), "/"), "/")
|
||||
pathParts := strings.Split(strings.Trim(normalizePath(path), "/"), "/")
|
||||
if len(patternParts) == 1 && patternParts[0] == "" {
|
||||
return len(pathParts) == 1 && pathParts[0] == ""
|
||||
func splitPath(path string) []string {
|
||||
return strings.Split(strings.Trim(normalizePath(path), "/"), "/")
|
||||
}
|
||||
|
||||
// Lookup accepts either a canonical Gin route template or an actual URL. A
|
||||
// configured router prefix is tolerated by matching canonical paths by suffix.
|
||||
// Every middleware resolves the descriptor on each request, so the pattern set
|
||||
// is pre-split at init: a miss on the exact key costs one path split and a scan
|
||||
// of the entries declared for that method, not a full catalog walk.
|
||||
func Lookup(method, path string) (Descriptor, bool) {
|
||||
if descriptor, ok := exact[descriptorKey(method, path)]; ok {
|
||||
return descriptor, true
|
||||
}
|
||||
if len(pathParts) < len(patternParts) {
|
||||
parts := splitPath(path)
|
||||
for _, entry := range byMethod[strings.ToUpper(strings.TrimSpace(method))] {
|
||||
if entry.matches(parts) {
|
||||
return entry.descriptor, true
|
||||
}
|
||||
}
|
||||
return Descriptor{}, false
|
||||
}
|
||||
|
||||
// matches compares the pre-split pattern against a pre-split request path.
|
||||
// Canonical patterns match by suffix so a router prefix is tolerated; a
|
||||
// wildcard tail absorbs any number of trailing segments, so its fixed head is
|
||||
// searched at every offset that leaves the wildcard something to consume.
|
||||
func (c compiled) matches(pathParts []string) bool {
|
||||
if len(c.parts) == 0 {
|
||||
return !c.wildcard && len(pathParts) == 1 && pathParts[0] == ""
|
||||
}
|
||||
if len(pathParts) < len(c.parts) {
|
||||
return false
|
||||
}
|
||||
offset := len(pathParts) - len(patternParts)
|
||||
for index, patternPart := range patternParts {
|
||||
if strings.HasPrefix(patternPart, "*") {
|
||||
return true
|
||||
if c.wildcard {
|
||||
for offset := 0; offset+len(c.parts) < len(pathParts); offset++ {
|
||||
if matchAt(c.parts, pathParts, offset) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return matchAt(c.parts, pathParts, len(pathParts)-len(c.parts))
|
||||
}
|
||||
|
||||
func matchAt(patternParts, pathParts []string, offset int) bool {
|
||||
for index, patternPart := range patternParts {
|
||||
actual := pathParts[offset+index]
|
||||
if strings.HasPrefix(patternPart, ":") {
|
||||
if actual == "" {
|
||||
@@ -295,22 +383,6 @@ func pathMatches(pattern, path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Lookup accepts either a canonical Gin route template or an actual URL. A
|
||||
// configured router prefix is tolerated by matching canonical paths by suffix.
|
||||
func Lookup(method, path string) (Descriptor, bool) {
|
||||
key := descriptorKey(method, path)
|
||||
if value, ok := routes[key]; ok {
|
||||
return descriptorFrom(key, value), true
|
||||
}
|
||||
for key, value := range routes {
|
||||
descriptor := descriptorFrom(key, value)
|
||||
if strings.EqualFold(descriptor.Method, method) && pathMatches(descriptor.Path, path) {
|
||||
return descriptor, true
|
||||
}
|
||||
}
|
||||
return Descriptor{}, false
|
||||
}
|
||||
|
||||
func Describe(method, path string) Descriptor {
|
||||
if descriptor, ok := Lookup(method, path); ok {
|
||||
return descriptor
|
||||
|
||||
@@ -28,6 +28,15 @@ func TestRoutePoliciesShareOneDescriptor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupMatchesWildcardTail(t *testing.T) {
|
||||
if descriptor, ok := Lookup("GET", "/api/swagger/index.html"); !ok || !descriptor.Public || descriptor.Path != "/swagger/*any" {
|
||||
t.Fatalf("wildcard descriptor = %#v, ok=%v", descriptor, ok)
|
||||
}
|
||||
if _, ok := Lookup("GET", "/api/swagger"); ok {
|
||||
t.Fatal("wildcard route matched without a tail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryRouteHasAPIMetadata(t *testing.T) {
|
||||
for _, descriptor := range Descriptors() {
|
||||
if descriptor.Group == "" || descriptor.Description == "" {
|
||||
|
||||
@@ -21,10 +21,10 @@ import (
|
||||
)
|
||||
|
||||
func NewGinEngine(runtime *config.Store, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
|
||||
return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, platformmodule.NewRuntime(router.NewRoutes(handlers)), nil)
|
||||
return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, router.NewRoutes(handlers), nil)
|
||||
}
|
||||
|
||||
func NewGinEngineWithRuntime(runtime *config.Store, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine {
|
||||
func NewGinEngineWithRuntime(runtime *config.Store, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes platformmodule.RouteRegistrar, ws *websocket.Server) *gin.Engine {
|
||||
if runtime == nil {
|
||||
runtime = config.NewStore(nil)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// This file is the handler package's single vocabulary for HTTP replies: the
|
||||
// envelope helpers come from pkg/httpx and Claims from the auth middleware, so
|
||||
// handlers name one package instead of two.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/server/middleware"
|
||||
httpx "kra/pkg/httpx"
|
||||
"kra/pkg/httpx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -11,8 +11,7 @@ import (
|
||||
platformtask "kra/pkg/task"
|
||||
)
|
||||
|
||||
// TaskMethods is the system module's dependency-bearing task contribution.
|
||||
// Other modules provide their own contributor instead of editing this file.
|
||||
// TaskMethods owns the dependency-bearing built-in task handlers.
|
||||
type TaskMethods struct {
|
||||
tasks *taskbiz.TaskUsecase
|
||||
maintenance *system.MaintenanceUsecase
|
||||
@@ -24,8 +23,6 @@ func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceU
|
||||
return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime}
|
||||
}
|
||||
|
||||
var _ platformtask.Contributor = (*TaskMethods)(nil)
|
||||
|
||||
func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) {
|
||||
if methods == nil || registry == nil {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user