feat(config): 增强配置解析的语法校验与错误处理
- 重构 ParseConfigToKVMap 使其在解析失败时返回 error 而非静默忽略 - 新增 ValidateConfig 方法,在保存和更新配置前校验语法正确性 - 将 XML 解析器从简单字符串匹配改为标准库 xml.NewDecoder,支持嵌套标签和实体转义 - 在配置保存和更新接口中集成语法校验,校验失败返回 400 错误 - API 拉取配置时解析失败的条目记录日志并跳过,避免整体请求中断 - 新增 config_parse_test.go 覆盖各配置类型的正常与异常场景
This commit is contained in:
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if h.addChangedConfigs(result, &cfg, req.FetchAll) {
|
||||||
configIds = append(configIds, cfg.Id)
|
configIds = append(configIds, cfg.Id)
|
||||||
h.addChangedConfigs(result, &cfg, req.FetchAll)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
if h.addChangedConfigs(result, &cfg, req.FetchAll) {
|
||||||
configIds = append(configIds, cfg.Id)
|
configIds = append(configIds, cfg.Id)
|
||||||
h.addChangedConfigs(result, &cfg, req.FetchAll)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
valEnd := strings.Index(line, "</entry>")
|
|
||||||
if valEnd < 0 {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
value := strings.TrimSpace(line[valStart+1 : valEnd])
|
|
||||||
if key != "" && value != "" {
|
|
||||||
result[key] = value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConfigParseService) parseYAML(result map[string]interface{}, content string) {
|
if depth != 0 {
|
||||||
|
return errors.New("XML 标签未闭合")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 & 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user