feat(config): 增强配置解析的语法校验与错误处理

- 重构 ParseConfigToKVMap 使其在解析失败时返回 error 而非静默忽略
- 新增 ValidateConfig 方法,在保存和更新配置前校验语法正确性
- 将 XML 解析器从简单字符串匹配改为标准库 xml.NewDecoder,支持嵌套标签和实体转义
- 在配置保存和更新接口中集成语法校验,校验失败返回 400 错误
- API 拉取配置时解析失败的条目记录日志并跳过,避免整体请求中断
- 新增 config_parse_test.go 覆盖各配置类型的正常与异常场景
This commit is contained in:
2026-09-08 21:19:55 +08:00
parent 92c09df103
commit a2c26cea98
4 changed files with 219 additions and 54 deletions
+14
View File
@@ -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 { if _, err := dao.Engine.Insert(&config); err != nil {
c.JSON(http.StatusOK, model.WrapperResponse{Code: 500, Msg: "保存失败"}) c.JSON(http.StatusOK, model.WrapperResponse{Code: 500, Msg: "保存失败"})
return return
@@ -264,6 +269,15 @@ func (h *ConfigHandler) Update(c *gin.Context) {
return 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 // save history
h.saveHistory(&config, userInfo.Name, "update") h.saveHistory(&config, userInfo.Name, "update")
+33 -10
View File
@@ -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)", 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) req.Env, version, *app.GroupId).Find(&globalConfigs)
for _, cfg := range 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) configIds = append(configIds, cfg.Id)
kvMap := h.parseService.ParseConfigToKVMap(&cfg)
for k, v := range kvMap { for k, v := range kvMap {
result[k] = v 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 = ?", dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ?",
req.Env, version, app.Id).Find(&appConfigs) req.Env, version, app.Id).Find(&appConfigs)
for _, cfg := range 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) configIds = append(configIds, cfg.Id)
kvMap := h.parseService.ParseConfigToKVMap(&cfg)
for k, v := range kvMap { for k, v := range kvMap {
result[k] = v 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 <= ?", 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) req.Env, version, *app.GroupId, lastUpdateTime, upperBound).Find(&deletedGlobalConfigs)
for _, cfg := range 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 { for k := range kvMap {
deletedKeys = append(deletedKeys, k) 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 <= ?", 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) req.Env, version, app.Id, lastUpdateTime, upperBound).Find(&deletedAppConfigs)
for _, cfg := range 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 { for k := range kvMap {
deletedKeys = append(deletedKeys, k) 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 <= ?", 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) req.Env, version, *app.GroupId, lastUpdateTime, upperBound).Find(&globalConfigs)
for _, cfg := range globalConfigs { for _, cfg := range globalConfigs {
configIds = append(configIds, cfg.Id) if h.addChangedConfigs(result, &cfg, req.FetchAll) {
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 <= ?", 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) req.Env, version, app.Id, lastUpdateTime, upperBound).Find(&appConfigs1)
for _, cfg := range appConfigs1 { for _, cfg := range appConfigs1 {
configIds = append(configIds, cfg.Id) if h.addChangedConfigs(result, &cfg, req.FetchAll) {
h.addChangedConfigs(result, &cfg, req.FetchAll) configIds = append(configIds, cfg.Id)
}
} }
result["latest.fetch.timestamp"] = strconv.FormatInt(upperBound.UnixMilli(), 10) 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}) c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: result})
} }
func (h *ConfigApiHandler) addChangedConfigs(result map[string]string, config *model.AppConfig, fetchAll bool) { func (h *ConfigApiHandler) addChangedConfigs(result map[string]string, config *model.AppConfig, fetchAll bool) bool {
kvMap := h.parseService.ParseConfigToKVMap(config) 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 { for k, v := range kvMap {
result[k] = fmt.Sprintf("%v", v) result[k] = fmt.Sprintf("%v", v)
} }
return true
} }
func (h *ConfigApiHandler) recordClientInstances(ip string, configIds []int, appName, env, fetchType string) { func (h *ConfigApiHandler) recordClientInstances(ip string, configIds []int, appName, env, fetchType string) {
+82 -42
View File
@@ -2,7 +2,10 @@ package service
import ( import (
"encoding/json" "encoding/json"
"encoding/xml"
"errors"
"fmt" "fmt"
"io"
"kenaito-conf/server/internal/model" "kenaito-conf/server/internal/model"
"strings" "strings"
@@ -15,29 +18,44 @@ func NewConfigParseService() *ConfigParseService {
return &ConfigParseService{} return &ConfigParseService{}
} }
// ParseConfigToKVMap parses config content based on type // ParseConfigToKVMap 解析配置内容,语法错误时返回 error 而不是静默返回空结果。
func (s *ConfigParseService) ParseConfigToKVMap(config *model.AppConfig) map[string]interface{} { func (s *ConfigParseService) ParseConfigToKVMap(config *model.AppConfig) (map[string]interface{}, error) {
result := make(map[string]interface{}) result := make(map[string]interface{})
switch config.Type { switch config.Type {
case 1: // file case 1: // file
if strings.HasSuffix(strings.ToLower(config.Name), ".xml") { name := strings.ToLower(config.Name)
s.parseXML(result, config.Contents) switch {
} else if strings.HasSuffix(strings.ToLower(config.Name), ".yml") || case strings.HasSuffix(name, ".xml"):
strings.HasSuffix(strings.ToLower(config.Name), ".yaml") { if err := s.parseXML(result, config.Contents); err != nil {
s.parseYAML(result, config.Contents) return nil, fmt.Errorf("解析 XML 配置 %q 失败: %w", config.Name, err)
} else { }
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) s.parseProperties(result, config.Contents)
} }
case 2: // key-value case 2: // key-value
result[config.Name] = config.Contents result[config.Name] = config.Contents
case 3: // JSON 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 // resolve placeholders
s.resolvePlaceholders(result) 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) { 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) { // parseXML 使用标准库解析 <entry key="...">value</entry> 结构,
// simple XML parsing for <entry key="...">value</entry> format // 支持嵌套标签、CDATA 和实体转义,语法错误会返回 error。
// This is a simplified version - for production use a proper XML parser func (s *ConfigParseService) parseXML(result map[string]interface{}, content string) error {
lines := strings.Split(content, "\n") decoder := xml.NewDecoder(strings.NewReader(content))
for _, line := range lines {
line = strings.TrimSpace(line)
if !strings.Contains(line, "<entry") {
continue
}
// extract key
keyStart := strings.Index(line, `key="`)
if keyStart < 0 {
continue
}
keyStart += 5
keyEnd := strings.Index(line[keyStart:], `"`)
if keyEnd < 0 {
continue
}
key := line[keyStart : keyStart+keyEnd]
// extract value var (
valStart := strings.Index(line, ">") currentKey string
if valStart < 0 { currentValue strings.Builder
continue depth int
)
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
} }
valEnd := strings.Index(line, "</entry>") if err != nil {
if valEnd < 0 { return err
continue
} }
value := strings.TrimSpace(line[valStart+1 : valEnd])
if key != "" && value != "" { switch t := token.(type) {
result[key] = value 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{} var data map[string]interface{}
if err := yaml.Unmarshal([]byte(content), &data); err != nil { if err := yaml.Unmarshal([]byte(content), &data); err != nil {
return return err
} }
s.flattenYAML("", result, data) s.flattenYAML("", result, data)
return nil
} }
func (s *ConfigParseService) flattenYAML(prefix string, result map[string]interface{}, data map[string]interface{}) { func (s *ConfigParseService) flattenYAML(prefix string, result map[string]interface{}, data map[string]interface{}) {
@@ -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: `<properties><entry key="a">1 &amp; 2</entry></properties>`},
wantKey: "a",
wantVal: "1 & 2",
},
{
name: "invalid xml",
config: model.AppConfig{Type: 1, Name: "app.xml", Contents: `<properties><entry key="a">1</properties>`},
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)
}
})
}
}