From c4b114762b80ff013f4001f5eeb327eb15a875b2 Mon Sep 17 00:00:00 2001 From: yvan <8574526@qq.com> Date: Sat, 15 Aug 2026 23:53:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/config.yaml | 143 ++++++++++++++++++++++++++++- internal/data/config_management.go | 43 +++++++++ internal/data/config_store.go | 88 +++++++++++++++++- internal/data/system_init.go | 24 ++++- web/src/view/init/index.vue | 2 +- 5 files changed, 288 insertions(+), 12 deletions(-) diff --git a/configs/config.yaml b/configs/config.yaml index cc5c231..649a3d2 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -1,22 +1,30 @@ server: http: + network: tcp addr: 0.0.0.0:8000 timeout: 600s data: database: driver: mysql - source: root:root@tcp(127.0.0.1:3306)/test?timeout=5s&parseTime=True&loc=Local&charset=utf8mb4 + source: root:root@tcp(127.0.0.1:3306)/kra?timeout=5s&parseTime=True&loc=Local&charset=utf8mb4 host: 127.0.0.1 port: "3306" user: root password: "12345678" - name: test + name: kra config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4 + path: "" + alias_name: "" + disable: false + prefix: "" + engine: InnoDB log_mode: info max_idle_conns: 10 max_open_conns: 100 conn_max_lifetime: 3600 + singular: false redis: + network: tcp name: default addr: 127.0.0.1:6379 password: "" @@ -25,11 +33,58 @@ data: cluster_addrs: [] read_timeout: 0.2s write_timeout: 0.2s + # Additional databases use alias_name as the lookup key. Disabled entries + # remain available as configuration examples without opening connections. database_list: [] + # Example: + # database_list: + # - driver: mysql + # source: "" + # host: 127.0.0.1 + # port: "3306" + # user: root + # password: "" + # name: business + # config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4 + # path: "" + # alias_name: business + # disable: true + # prefix: "" + # engine: InnoDB + # log_mode: info + # max_idle_conns: 10 + # max_open_conns: 100 + # conn_max_lifetime: 3600 + # singular: false redis_list: [] + # Example: + # redis_list: + # - network: tcp + # name: cache + # addr: 127.0.0.1:6379 + # password: "" + # db: 0 + # use_cluster: false + # cluster_addrs: [] + # read_timeout: 0.2s + # write_timeout: 0.2s mongo: - hosts: [] + coll: "" + options: "" + database: "" + username: "" + password: "" + auth_source: "" + min_pool_size: 0 + max_pool_size: 100 + socket_timeout_ms: 0 + connect_timeout_ms: 0 + is_zap: false + hosts: + - host: "" + port: "" admin: + # GVA system.router-prefix maps to this transport prefix. router_prefix: "" jwt: # Production deployments must override this value with a private secret. @@ -46,6 +101,7 @@ admin: store_path: uploads/file path_prefix: uploads/file media: + # Upload chunks are stored below .chunks in the selected storage backend. session_ttl: 24 max_file_size: 0 system: @@ -72,6 +128,13 @@ admin: cors: mode: whitelist whitelist: [] + # Example: + # whitelist: + # - allow_origin: https://admin.example.com + # allow_headers: Content-Type,Authorization,X-Token,X-User-Id + # allow_methods: POST,GET,PUT,DELETE,OPTIONS + # expose_headers: Content-Length,Content-Type + # allow_credentials: true app: node: "" app_id: kra @@ -82,6 +145,80 @@ admin: # local, qiniu, aliyun-oss, huawei-obs, tencent-cos, aws-s3, # cloudflare-r2 or minio type: local + qiniu: + zone: ZoneHuadong + bucket: "" + base_url: "" + access_key: "" + secret_key: "" + use_https: false + use_cdn_domains: false + aliyun_oss: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: true + force_path_style: false + account_id: "" + huawei_obs: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: true + force_path_style: false + account_id: "" + tencent_cos: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: true + force_path_style: false + account_id: "" + aws_s3: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: true + force_path_style: false + account_id: "" + cloudflare_r2: + endpoint: "" + region: auto + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: uploads + use_ssl: true + force_path_style: false + account_id: "" + minio: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: false + force_path_style: true + account_id: "" email: # Leave host/from/secret empty to disable SMTP error notifications. to: "" diff --git a/internal/data/config_management.go b/internal/data/config_management.go index 5a06a53..68fa5ec 100644 --- a/internal/data/config_management.go +++ b/internal/data/config_management.go @@ -118,6 +118,9 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json nextAdmin.Email.Secret = value.Email.Secret } } + if err := refreshDatabaseSources(nextData); err != nil { + return err + } nextAdmin.ConfigPath = currentAdmin.ConfigPath dataRaw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(nextData) if err != nil { @@ -151,6 +154,46 @@ func cloneDataConfig(value *conf.Data) *conf.Data { return proto.Clone(value).(*conf.Data) } +func refreshDatabaseSources(value *conf.Data) error { + if value == nil { + return nil + } + if err := refreshDatabaseSource(value.Database); err != nil { + return err + } + for _, database := range value.DatabaseList { + if database == nil || database.Disable { + continue + } + if err := refreshDatabaseSource(database); err != nil { + return err + } + } + return nil +} + +func refreshDatabaseSource(database *conf.Data_Database) error { + if database == nil { + return nil + } + hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || + database.Password != "" || database.Name != "" || database.Config != "" || database.Path != "" + if !hasStructuredConfig { + // A source-only configuration is an intentional escape hatch for custom + // driver DSNs; do not reinterpret it as the structured form. + return nil + } + previousSource := database.Source + database.Source = "" + source, err := databaseDSN(database, "") + if err != nil { + database.Source = previousSource + return err + } + database.Source = source + return nil +} + func durationString(value *durationpb.Duration) string { if value == nil { return "0s" diff --git a/internal/data/config_store.go b/internal/data/config_store.go index 69b3fd9..934a998 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -15,7 +15,10 @@ import ( ) func protoMap(message proto.Message) (map[string]any, error) { - raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(message) + raw, err := (protojson.MarshalOptions{ + UseProtoNames: true, + EmitDefaultValues: true, + }).Marshal(message) if err != nil { return nil, err } @@ -44,7 +47,7 @@ func setYAMLMapping(node *yaml.Node, key string, value any) error { } for i := 0; i < len(node.Content); i += 2 { if node.Content[i].Value == key { - node.Content[i+1] = replacement.Content[0] + mergeYAMLNode(node.Content[i+1], replacement.Content[0]) return nil } } @@ -52,6 +55,35 @@ func setYAMLMapping(node *yaml.Node, key string, value any) error { return nil } +// mergeYAMLNode updates values produced from the runtime configuration while +// retaining keys that are not represented by the protobuf and comments that +// were already present in the user's config file. Replacing an entire mapping +// with protojson output would discard configuration hints and extension keys. +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 (d *Data) persistConfig() error { dataConfig, adminConfig := d.runtime.Values() return d.persistConfigValues(dataConfig, adminConfig) @@ -90,7 +122,57 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con if err = setYAMLMapping(&document, "admin", adminValue); err != nil { return err } - output, err := yaml.Marshal(&document) + return writeConfigDocument(configPath, &document) +} + +// persistDatabaseConfig writes only the database selected on the init page. +// Initialization must not serialize the partially populated runtime config +// back over the template: doing so removes all omitted/default settings from +// data and admin and leaves the next startup with no visible configuration. +func (d *Data) persistDatabaseConfig(database *conf.Data_Database) error { + d.configMu.Lock() + defer d.configMu.Unlock() + + configPath := d.runtime.ConfigPath() + if configPath == "" { + return nil + } + raw, err := os.ReadFile(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 + } + // Do not marshal the init request through protojson here. Proto3 omits + // empty and zero values, which makes a merge retain stale values from the + // template (an empty database password is the common example). Update the + // fields owned by the init page explicitly and leave advanced database, + // Redis, Mongo, admin and server settings untouched. + databaseValue := map[string]any{ + "driver": database.Driver, + "source": source, + "host": database.Host, + "port": database.Port, + "user": database.User, + "password": database.Password, + "name": database.Name, + "config": database.Config, + "path": database.Path, + } + if err = setYAMLMapping(&document, "data", map[string]any{"database": databaseValue}); err != nil { + return err + } + return writeConfigDocument(configPath, &document) +} + +func writeConfigDocument(configPath string, document *yaml.Node) error { + output, err := yaml.Marshal(document) if err != nil { return err } diff --git a/internal/data/system_init.go b/internal/data/system_init.go index ae257a7..e95a854 100644 --- a/internal/data/system_init.go +++ b/internal/data/system_init.go @@ -68,7 +68,24 @@ func (r *initializationRepo) IsInitialized(ctx context.Context) (bool, error) { } func (r *initializationRepo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error { - config := &conf.Data_Database{Driver: input.Driver, Host: input.Host, Port: input.Port, User: input.User, Password: input.Password, Name: input.Name, Path: input.Path, Config: input.Config} + config := &conf.Data_Database{} + if current := r.data.runtime.Data(); current != nil && current.Database != nil { + config = proto.Clone(current.Database).(*conf.Data_Database) + } + config.Driver = input.Driver + config.Host = input.Host + config.Port = input.Port + config.User = input.User + config.Password = input.Password + config.Name = input.Name + config.Path = input.Path + config.Config = input.Config + config.Source = "" + source, err := databaseDSN(config, "") + if err != nil { + return err + } + config.Source = source r.data.initMu.Lock() defer r.data.initMu.Unlock() initialized, err := r.IsInitialized(ctx) @@ -197,10 +214,7 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database }); err != nil { return err } - currentData, currentAdmin := r.data.runtime.Values() - nextData := proto.Clone(currentData).(*conf.Data) - nextData.Database = config - if err := r.data.persistConfigValues(nextData, currentAdmin); err != nil { + if err := r.data.persistDatabaseConfig(config); err != nil { return fmt.Errorf("persist database configuration: %w", err) } r.data.activateDatabase(candidate, config) diff --git a/web/src/view/init/index.vue b/web/src/view/init/index.vue index 82c36c3..c853859 100644 --- a/web/src/view/init/index.vue +++ b/web/src/view/init/index.vue @@ -21,7 +21,7 @@ tabindex="0" @scroll="updateNoticeReadState" > -
+