From a2c26cea98820f5f3c42472f7a95178511a84fc5 Mon Sep 17 00:00:00 2001 From: Odboy Date: Tue, 8 Sep 2026 21:19:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E5=A2=9E=E5=BC=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=A7=A3=E6=9E=90=E7=9A=84=E8=AF=AD=E6=B3=95=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 ParseConfigToKVMap 使其在解析失败时返回 error 而非静默忽略 - 新增 ValidateConfig 方法,在保存和更新配置前校验语法正确性 - 将 XML 解析器从简单字符串匹配改为标准库 xml.NewDecoder,支持嵌套标签和实体转义 - 在配置保存和更新接口中集成语法校验,校验失败返回 400 错误 - API 拉取配置时解析失败的条目记录日志并跳过,避免整体请求中断 - 新增 config_parse_test.go 覆盖各配置类型的正常与异常场景 --- server/internal/handler/config.go | 14 ++ server/internal/handler/config_api.go | 43 +++++-- server/internal/service/config_parse.go | 128 ++++++++++++------- server/internal/service/config_parse_test.go | 88 +++++++++++++ 4 files changed, 219 insertions(+), 54 deletions(-) create mode 100644 server/internal/service/config_parse_test.go diff --git a/server/internal/handler/config.go b/server/internal/handler/config.go index bb2209d..0daa12d 100644 --- a/server/internal/handler/config.go +++ b/server/internal/handler/config.go @@ -231,6 +231,11 @@ func (h *ConfigHandler) Add(c *gin.Context) { } } + if err := h.parseService.ValidateConfig(&config); err != nil { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: err.Error()}) + return + } + if _, err := dao.Engine.Insert(&config); err != nil { c.JSON(http.StatusOK, model.WrapperResponse{Code: 500, Msg: "保存失败"}) return @@ -264,6 +269,15 @@ func (h *ConfigHandler) Update(c *gin.Context) { return } + // 校验新内容语法,避免保存后客户端解析失败 + newConfig := config + newConfig.Contents = req.Contents + newConfig.Version = req.Version + if err := h.parseService.ValidateConfig(&newConfig); err != nil { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: err.Error()}) + return + } + // save history h.saveHistory(&config, userInfo.Name, "update") diff --git a/server/internal/handler/config_api.go b/server/internal/handler/config_api.go index ec53d4b..74ad5c0 100644 --- a/server/internal/handler/config_api.go +++ b/server/internal/handler/config_api.go @@ -108,8 +108,12 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) { dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND group_id = ? AND (app_id is null OR app_id = 0)", req.Env, version, *app.GroupId).Find(&globalConfigs) for _, cfg := range globalConfigs { + kvMap, err := h.parseService.ParseConfigToKVMap(&cfg) + if err != nil { + logger.SugaredLogger.Errorf("[FetchAllConfigs] parse config %d (%s) error: %v", cfg.Id, cfg.Name, err) + continue + } configIds = append(configIds, cfg.Id) - kvMap := h.parseService.ParseConfigToKVMap(&cfg) for k, v := range kvMap { result[k] = v } @@ -121,8 +125,12 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) { dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ?", req.Env, version, app.Id).Find(&appConfigs) for _, cfg := range appConfigs { + kvMap, err := h.parseService.ParseConfigToKVMap(&cfg) + if err != nil { + logger.SugaredLogger.Errorf("[FetchAllConfigs] parse config %d (%s) error: %v", cfg.Id, cfg.Name, err) + continue + } configIds = append(configIds, cfg.Id) - kvMap := h.parseService.ParseConfigToKVMap(&cfg) for k, v := range kvMap { result[k] = v } @@ -197,7 +205,11 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { dao.Engine.Where("env = ? AND version = ? AND enabled = 0 AND group_id = ? AND (app_id is null OR app_id = 0) AND updated_at > ? AND updated_at <= ?", req.Env, version, *app.GroupId, lastUpdateTime, upperBound).Find(&deletedGlobalConfigs) for _, cfg := range deletedGlobalConfigs { - kvMap := h.parseService.ParseConfigToKVMap(&cfg) + kvMap, err := h.parseService.ParseConfigToKVMap(&cfg) + if err != nil { + logger.SugaredLogger.Errorf("[FetchChangedConfigs] parse deleted config %d (%s) error: %v", cfg.Id, cfg.Name, err) + continue + } for k := range kvMap { deletedKeys = append(deletedKeys, k) } @@ -207,7 +219,11 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { dao.Engine.Where("env = ? AND version = ? AND enabled = 0 AND app_id = ? AND updated_at > ? AND updated_at <= ?", req.Env, version, app.Id, lastUpdateTime, upperBound).Find(&deletedAppConfigs) for _, cfg := range deletedAppConfigs { - kvMap := h.parseService.ParseConfigToKVMap(&cfg) + kvMap, err := h.parseService.ParseConfigToKVMap(&cfg) + if err != nil { + logger.SugaredLogger.Errorf("[FetchChangedConfigs] parse deleted config %d (%s) error: %v", cfg.Id, cfg.Name, err) + continue + } for k := range kvMap { deletedKeys = append(deletedKeys, k) } @@ -222,8 +238,9 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND group_id = ? AND (app_id is null OR app_id = 0) AND updated_at > ? AND updated_at <= ?", req.Env, version, *app.GroupId, lastUpdateTime, upperBound).Find(&globalConfigs) for _, cfg := range globalConfigs { - configIds = append(configIds, cfg.Id) - h.addChangedConfigs(result, &cfg, req.FetchAll) + if h.addChangedConfigs(result, &cfg, req.FetchAll) { + configIds = append(configIds, cfg.Id) + } } } @@ -232,8 +249,9 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ? AND updated_at > ? AND updated_at <= ?", req.Env, version, app.Id, lastUpdateTime, upperBound).Find(&appConfigs1) for _, cfg := range appConfigs1 { - configIds = append(configIds, cfg.Id) - h.addChangedConfigs(result, &cfg, req.FetchAll) + if h.addChangedConfigs(result, &cfg, req.FetchAll) { + configIds = append(configIds, cfg.Id) + } } result["latest.fetch.timestamp"] = strconv.FormatInt(upperBound.UnixMilli(), 10) @@ -266,11 +284,16 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: result}) } -func (h *ConfigApiHandler) addChangedConfigs(result map[string]string, config *model.AppConfig, fetchAll bool) { - kvMap := h.parseService.ParseConfigToKVMap(config) +func (h *ConfigApiHandler) addChangedConfigs(result map[string]string, config *model.AppConfig, fetchAll bool) bool { + kvMap, err := h.parseService.ParseConfigToKVMap(config) + if err != nil { + logger.SugaredLogger.Errorf("[FetchChangedConfigs] parse config %d (%s) error: %v", config.Id, config.Name, err) + return false + } for k, v := range kvMap { result[k] = fmt.Sprintf("%v", v) } + return true } func (h *ConfigApiHandler) recordClientInstances(ip string, configIds []int, appName, env, fetchType string) { diff --git a/server/internal/service/config_parse.go b/server/internal/service/config_parse.go index b645485..e4983db 100644 --- a/server/internal/service/config_parse.go +++ b/server/internal/service/config_parse.go @@ -2,7 +2,10 @@ package service import ( "encoding/json" + "encoding/xml" + "errors" "fmt" + "io" "kenaito-conf/server/internal/model" "strings" @@ -15,29 +18,44 @@ func NewConfigParseService() *ConfigParseService { return &ConfigParseService{} } -// ParseConfigToKVMap parses config content based on type -func (s *ConfigParseService) ParseConfigToKVMap(config *model.AppConfig) map[string]interface{} { +// ParseConfigToKVMap 解析配置内容,语法错误时返回 error 而不是静默返回空结果。 +func (s *ConfigParseService) ParseConfigToKVMap(config *model.AppConfig) (map[string]interface{}, error) { result := make(map[string]interface{}) switch config.Type { case 1: // file - if strings.HasSuffix(strings.ToLower(config.Name), ".xml") { - s.parseXML(result, config.Contents) - } else if strings.HasSuffix(strings.ToLower(config.Name), ".yml") || - strings.HasSuffix(strings.ToLower(config.Name), ".yaml") { - s.parseYAML(result, config.Contents) - } else { + name := strings.ToLower(config.Name) + switch { + case strings.HasSuffix(name, ".xml"): + if err := s.parseXML(result, config.Contents); err != nil { + return nil, fmt.Errorf("解析 XML 配置 %q 失败: %w", config.Name, err) + } + case strings.HasSuffix(name, ".yml"), strings.HasSuffix(name, ".yaml"): + if err := s.parseYAML(result, config.Contents); err != nil { + return nil, fmt.Errorf("解析 YAML 配置 %q 失败: %w", config.Name, err) + } + default: s.parseProperties(result, config.Contents) } case 2: // key-value result[config.Name] = config.Contents case 3: // JSON - json.Unmarshal([]byte(config.Contents), &result) + if err := json.Unmarshal([]byte(config.Contents), &result); err != nil { + return nil, fmt.Errorf("解析 JSON 配置 %q 失败: %w", config.Name, err) + } + default: + return nil, fmt.Errorf("不支持的配置类型: %d", config.Type) } // resolve placeholders s.resolvePlaceholders(result) - return result + return result, nil +} + +// ValidateConfig 在保存配置前校验语法。 +func (s *ConfigParseService) ValidateConfig(config *model.AppConfig) error { + _, err := s.ParseConfigToKVMap(config) + return err } func (s *ConfigParseService) parseProperties(result map[string]interface{}, content string) { @@ -59,49 +77,71 @@ func (s *ConfigParseService) parseProperties(result map[string]interface{}, cont } } -func (s *ConfigParseService) parseXML(result map[string]interface{}, content string) { - // simple XML parsing for value format - // This is a simplified version - for production use a proper XML parser - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if !strings.Contains(line, "value 结构, +// 支持嵌套标签、CDATA 和实体转义,语法错误会返回 error。 +func (s *ConfigParseService) parseXML(result map[string]interface{}, content string) error { + decoder := xml.NewDecoder(strings.NewReader(content)) - // extract value - valStart := strings.Index(line, ">") - if valStart < 0 { - continue + var ( + currentKey string + currentValue strings.Builder + depth int + ) + + for { + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + break } - valEnd := strings.Index(line, "") - if valEnd < 0 { - continue + if err != nil { + return err } - value := strings.TrimSpace(line[valStart+1 : valEnd]) - if key != "" && value != "" { - result[key] = value + + switch t := token.(type) { + case xml.StartElement: + if depth == 0 && t.Name.Local == "entry" { + currentKey = "" + for _, attr := range t.Attr { + if attr.Name.Local == "key" { + currentKey = attr.Value + break + } + } + currentValue.Reset() + depth = 1 + } else if depth > 0 { + depth++ + } + case xml.CharData: + if depth > 0 { + currentValue.Write(t) + } + case xml.EndElement: + if depth > 0 { + depth-- + if depth == 0 { + value := strings.TrimSpace(currentValue.String()) + if currentKey != "" && value != "" { + result[currentKey] = value + } + } + } } } + + if depth != 0 { + return errors.New("XML 标签未闭合") + } + return nil } -func (s *ConfigParseService) parseYAML(result map[string]interface{}, content string) { +func (s *ConfigParseService) parseYAML(result map[string]interface{}, content string) error { var data map[string]interface{} if err := yaml.Unmarshal([]byte(content), &data); err != nil { - return + return err } s.flattenYAML("", result, data) + return nil } func (s *ConfigParseService) flattenYAML(prefix string, result map[string]interface{}, data map[string]interface{}) { @@ -134,7 +174,7 @@ func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, val result := value maxIterations := 100 processedPlaceholders := make(map[string]bool) - + for i := 0; i < maxIterations; i++ { start := strings.Index(result, "${") if start < 0 { @@ -173,7 +213,7 @@ func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, val // 标记已处理 processedPlaceholders[refKey] = true - + result = result[:start] + replacement + result[end+1:] } return result diff --git a/server/internal/service/config_parse_test.go b/server/internal/service/config_parse_test.go new file mode 100644 index 0000000..ce74a2e --- /dev/null +++ b/server/internal/service/config_parse_test.go @@ -0,0 +1,88 @@ +package service + +import ( + "fmt" + "testing" + + "kenaito-conf/server/internal/model" +) + +func TestParseConfigToKVMap(t *testing.T) { + svc := NewConfigParseService() + + tests := []struct { + name string + config model.AppConfig + wantKey string + wantVal string + wantErr bool + }{ + { + name: "properties with placeholder", + config: model.AppConfig{Type: 1, Name: "app.properties", Contents: "a=1\nb=${a}-2\n"}, + wantKey: "b", + wantVal: "1-2", + }, + { + name: "json", + config: model.AppConfig{Type: 3, Name: "app.json", Contents: `{"a":"1","b":"2"}`}, + wantKey: "a", + wantVal: "1", + }, + { + name: "invalid json", + config: model.AppConfig{Type: 3, Name: "app.json", Contents: `{"a":`}, + wantErr: true, + }, + { + name: "yaml", + config: model.AppConfig{Type: 1, Name: "app.yaml", Contents: "a:\n b: 1\n"}, + wantKey: "a.b", + wantVal: "1", + }, + { + name: "invalid yaml", + config: model.AppConfig{Type: 1, Name: "app.yaml", Contents: "a: [1,\n"}, + wantErr: true, + }, + { + name: "xml with escaped value", + config: model.AppConfig{Type: 1, Name: "app.xml", Contents: `1 & 2`}, + wantKey: "a", + wantVal: "1 & 2", + }, + { + name: "invalid xml", + config: model.AppConfig{Type: 1, Name: "app.xml", Contents: `1`}, + wantErr: true, + }, + { + name: "key-value", + config: model.AppConfig{Type: 2, Name: "a", Contents: "1"}, + wantKey: "a", + wantVal: "1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := svc.ParseConfigToKVMap(&tt.config) + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + value, ok := got[tt.wantKey] + if !ok { + t.Fatalf("key %q not found in %v", tt.wantKey, got) + } + if fmt.Sprint(value) != tt.wantVal { + t.Fatalf("value of %q = %v, want %q", tt.wantKey, value, tt.wantVal) + } + }) + } +}