mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-09 08:13:29 +08:00
- 支持 Nacos v1、v2、v3 就绪探测与受限账号连接 - 强化认证、缓存、单飞连接和关闭代际的并发边界 - 修复监听重复事件、导入身份碰撞及部分失败误报成功 - 补齐只读保护、结构化错误与六语言提示
616 lines
17 KiB
Go
616 lines
17 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"GoNavi-Wails/internal/connection"
|
||
"GoNavi-Wails/internal/logger"
|
||
"GoNavi-Wails/internal/nacos"
|
||
|
||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||
)
|
||
|
||
type nacosImportPreviewItem struct {
|
||
Index int `json:"index"`
|
||
DataID string `json:"dataId"`
|
||
Group string `json:"group"`
|
||
Type string `json:"type,omitempty"`
|
||
Exists bool `json:"exists"`
|
||
Selected bool `json:"selected"`
|
||
}
|
||
|
||
type nacosConfigIdentityKey struct {
|
||
group string
|
||
dataID string
|
||
}
|
||
|
||
type nacosImportPreview struct {
|
||
File string `json:"file"`
|
||
ExportedAt string `json:"exportedAt,omitempty"`
|
||
NamespaceID string `json:"namespaceId,omitempty"`
|
||
SourceAppName string `json:"sourceAppName,omitempty"`
|
||
Total int `json:"total"`
|
||
ExistsCount int `json:"existsCount"`
|
||
NewCount int `json:"newCount"`
|
||
Items []nacosImportPreviewItem `json:"items"`
|
||
}
|
||
|
||
// NacosExportConfigs exports configs from a namespace to a JSON file.
|
||
func (a *App) NacosExportConfigs(config connection.ConnectionConfig, options NacosExportConfigsOptions) connection.QueryResult {
|
||
config.Type = "nacos"
|
||
scope := strings.ToLower(strings.TrimSpace(options.Scope))
|
||
if scope == "" {
|
||
scope = "all"
|
||
}
|
||
namespaceID := strings.TrimSpace(options.NamespaceID)
|
||
namespaceName := strings.TrimSpace(options.NamespaceName)
|
||
if namespaceName == "" {
|
||
if namespaceID == "" {
|
||
namespaceName = "public"
|
||
} else {
|
||
namespaceName = namespaceID
|
||
}
|
||
}
|
||
|
||
defaultName := fmt.Sprintf("nacos-%s-configs.json", sanitizeNacosFilename(namespaceName))
|
||
if scope == "selected" {
|
||
defaultName = fmt.Sprintf("nacos-%s-selected-configs.json", sanitizeNacosFilename(namespaceName))
|
||
}
|
||
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||
Title: a.appText("file.backend.dialog.export_data", nil),
|
||
DefaultFilename: defaultName,
|
||
Filters: []runtime.FileFilter{
|
||
{
|
||
DisplayName: a.appText("file.backend.filter.json_files", nil),
|
||
Pattern: "*.json",
|
||
},
|
||
},
|
||
})
|
||
if err != nil || strings.TrimSpace(filename) == "" {
|
||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||
}
|
||
filename = normalizeNacosTransferFilename(filename)
|
||
|
||
ctx, cancel := a.nacosOperationContext(config)
|
||
defer cancel()
|
||
client, err := a.getNacosClientWithContext(ctx, config)
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
|
||
entries, err := collectNacosExportEntries(ctx, client, namespaceID, scope, options)
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
if len(entries) == 0 {
|
||
return connection.QueryResult{Success: false, Message: a.appText("nacos.backend.error.export_empty", nil)}
|
||
}
|
||
|
||
payload := nacos.NewTransferFile(namespaceID, namespaceName)
|
||
payload.Configs = entries
|
||
if err := nacos.WriteTransferFile(filename, payload); err != nil {
|
||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.write_failed", map[string]any{"detail": err.Error()})}
|
||
}
|
||
logger.Infof("Nacos 配置导出成功:ns=%s count=%d file=%s", namespaceName, len(entries), filename)
|
||
return connection.QueryResult{
|
||
Success: true,
|
||
Message: a.appText("nacos.backend.message.export_success", nil),
|
||
Data: map[string]any{
|
||
"exported": len(entries),
|
||
"file": filename,
|
||
},
|
||
}
|
||
}
|
||
|
||
// NacosPreviewImportConfigs opens a file and previews import conflicts.
|
||
func (a *App) NacosPreviewImportConfigs(config connection.ConnectionConfig, namespaceID string) connection.QueryResult {
|
||
config.Type = "nacos"
|
||
selection, err := a.openNacosImportTransferFileDialog()
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
if strings.TrimSpace(selection) == "" {
|
||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||
}
|
||
|
||
payload, err := nacos.ReadTransferFile(selection)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
|
||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.open_file_failed", map[string]any{"detail": err.Error()})}
|
||
}
|
||
var syntaxErr *json.SyntaxError
|
||
if errors.As(err, &syntaxErr) {
|
||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.import_json_parse_failed", map[string]any{"detail": err.Error()})}
|
||
}
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
|
||
ctx, cancel := a.nacosOperationContext(config)
|
||
defer cancel()
|
||
client, err := a.getNacosClientWithContext(ctx, config)
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
|
||
preview, err := buildNacosImportPreview(ctx, client, selection, namespaceID, payload)
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
return connection.QueryResult{Success: true, Data: preview}
|
||
}
|
||
|
||
// NacosImportConfigs imports configs from a transfer file.
|
||
func (a *App) NacosImportConfigs(config connection.ConnectionConfig, options NacosImportConfigsOptions) connection.QueryResult {
|
||
config.Type = "nacos"
|
||
if err := a.ensureNacosDataImportAllowed(config); err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
|
||
scope := strings.ToLower(strings.TrimSpace(options.Scope))
|
||
selectedByIndex := make(map[int]nacosConfigIdentityKey, len(options.Items))
|
||
selectedByIdentity := make(map[nacosConfigIdentityKey]struct{}, len(options.Items))
|
||
invalidIndexedSelection := false
|
||
for _, item := range options.Items {
|
||
key, ok := normalizeNacosConfigIdentityKey(item.Group, item.DataID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if item.Index == nil {
|
||
selectedByIdentity[key] = struct{}{}
|
||
continue
|
||
}
|
||
if *item.Index < 0 {
|
||
continue
|
||
}
|
||
if existing, exists := selectedByIndex[*item.Index]; exists && existing != key {
|
||
invalidIndexedSelection = true
|
||
}
|
||
selectedByIndex[*item.Index] = key
|
||
}
|
||
if scope == "selected" && len(selectedByIndex)+len(selectedByIdentity) == 0 {
|
||
return connection.QueryResult{
|
||
Success: false,
|
||
Message: a.appText("nacos.backend.error.import_selection_required", nil),
|
||
}
|
||
}
|
||
|
||
selection := strings.TrimSpace(options.File)
|
||
var err error
|
||
if selection == "" {
|
||
selection, err = a.openNacosImportTransferFileDialog()
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
}
|
||
if strings.TrimSpace(selection) == "" {
|
||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||
}
|
||
|
||
payload, err := nacos.ReadTransferFile(selection)
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
|
||
useSelected := scope == "selected"
|
||
if useSelected && (invalidIndexedSelection ||
|
||
!nacosImportSelectionMatchesPayload(payload.Configs, selectedByIndex, selectedByIdentity)) {
|
||
return connection.QueryResult{
|
||
Success: false,
|
||
Message: a.appText("nacos.backend.error.import_selection_required", nil),
|
||
}
|
||
}
|
||
|
||
ctx, cancel := a.nacosOperationContext(config)
|
||
defer cancel()
|
||
client, err := a.getNacosClientWithContext(ctx, config)
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
|
||
conflictMode := strings.ToLower(strings.TrimSpace(options.ConflictMode))
|
||
if conflictMode != "overwrite" {
|
||
conflictMode = "skip"
|
||
}
|
||
|
||
imported := 0
|
||
skipped := 0
|
||
failed := 0
|
||
var firstErr error
|
||
namespaceID := strings.TrimSpace(options.NamespaceID)
|
||
|
||
for index, item := range payload.Configs {
|
||
key, validIdentity := normalizeNacosConfigIdentityKey(item.Group, item.DataID)
|
||
if useSelected && (!validIdentity ||
|
||
!nacosImportRowSelected(index, key, selectedByIndex, selectedByIdentity)) {
|
||
continue
|
||
}
|
||
exists, existsErr := nacosConfigExists(ctx, client, namespaceID, item.Group, item.DataID)
|
||
if existsErr != nil {
|
||
failed++
|
||
if firstErr == nil {
|
||
firstErr = existsErr
|
||
}
|
||
continue
|
||
}
|
||
if exists && conflictMode == "skip" {
|
||
skipped++
|
||
continue
|
||
}
|
||
if err := client.PublishConfig(ctx, nacos.PublishRequest{
|
||
NamespaceID: namespaceID,
|
||
DataID: item.DataID,
|
||
Group: item.Group,
|
||
Content: item.Content,
|
||
Type: item.Type,
|
||
AppName: item.AppName,
|
||
Desc: item.Desc,
|
||
}); err != nil {
|
||
failed++
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
continue
|
||
}
|
||
imported++
|
||
}
|
||
|
||
resultData := map[string]any{
|
||
"imported": imported,
|
||
"skipped": skipped,
|
||
"failed": failed,
|
||
"file": selection,
|
||
}
|
||
logger.Infof("Nacos 配置导入完成:imported=%d skipped=%d failed=%d file=%s", imported, skipped, failed, selection)
|
||
if failed > 0 {
|
||
detail := ""
|
||
if firstErr != nil {
|
||
detail = firstErr.Error()
|
||
}
|
||
return connection.QueryResult{
|
||
Success: false,
|
||
Message: a.appText("nacos.backend.error.import_partial_failed", map[string]any{
|
||
"imported": imported,
|
||
"skipped": skipped,
|
||
"failed": failed,
|
||
"detail": detail,
|
||
}),
|
||
Data: resultData,
|
||
}
|
||
}
|
||
return connection.QueryResult{
|
||
Success: true,
|
||
Message: a.appText("nacos.backend.message.import_success", nil),
|
||
Data: resultData,
|
||
}
|
||
}
|
||
|
||
func (a *App) openNacosImportTransferFileDialog() (string, error) {
|
||
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||
Title: a.appText("file.backend.dialog.import_data", map[string]any{"table": "nacos"}),
|
||
Filters: []runtime.FileFilter{
|
||
{
|
||
DisplayName: a.appText("file.backend.filter.json_files", nil),
|
||
Pattern: "*.json",
|
||
},
|
||
{
|
||
DisplayName: a.appText("file.backend.filter.all_files", nil),
|
||
Pattern: "*",
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
func collectNacosExportEntries(
|
||
ctx context.Context,
|
||
client nacos.Client,
|
||
namespaceID, scope string,
|
||
options NacosExportConfigsOptions,
|
||
) ([]nacos.TransferConfigEntry, error) {
|
||
if scope == "selected" {
|
||
entries := make([]nacos.TransferConfigEntry, 0, len(options.Items))
|
||
for _, item := range options.Items {
|
||
dataID := strings.TrimSpace(item.DataID)
|
||
group := strings.TrimSpace(item.Group)
|
||
if dataID == "" {
|
||
continue
|
||
}
|
||
if group == "" {
|
||
group = "DEFAULT_GROUP"
|
||
}
|
||
detail, err := client.GetConfig(ctx, namespaceID, group, dataID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
entries = append(entries, nacos.TransferConfigEntry{
|
||
DataID: detail.DataID,
|
||
Group: detail.Group,
|
||
Content: detail.Content,
|
||
Type: detail.Type,
|
||
AppName: detail.AppName,
|
||
Desc: detail.Desc,
|
||
})
|
||
}
|
||
return entries, nil
|
||
}
|
||
|
||
// Export all: page through search API then fetch each content.
|
||
const pageSize = 100
|
||
pageNo := 1
|
||
entries := make([]nacos.TransferConfigEntry, 0, 64)
|
||
seen := make(map[nacosConfigIdentityKey]struct{})
|
||
for {
|
||
page, err := client.SearchConfigs(ctx, nacos.ConfigQuery{
|
||
NamespaceID: namespaceID,
|
||
PageNo: pageNo,
|
||
PageSize: pageSize,
|
||
Search: "blur",
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if page == nil || len(page.PageItems) == 0 {
|
||
break
|
||
}
|
||
for _, item := range page.PageItems {
|
||
key, validIdentity := normalizeNacosConfigIdentityKey(item.Group, item.DataID)
|
||
if !validIdentity {
|
||
continue
|
||
}
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
// Prefer content from list if present; otherwise fetch.
|
||
content := item.Content
|
||
typ := item.Type
|
||
appName := item.AppName
|
||
desc := item.Desc
|
||
if strings.TrimSpace(content) == "" {
|
||
detail, getErr := client.GetConfig(ctx, namespaceID, item.Group, item.DataID)
|
||
if getErr != nil {
|
||
return nil, getErr
|
||
}
|
||
content = detail.Content
|
||
if typ == "" {
|
||
typ = detail.Type
|
||
}
|
||
if appName == "" {
|
||
appName = detail.AppName
|
||
}
|
||
if desc == "" {
|
||
desc = detail.Desc
|
||
}
|
||
}
|
||
entries = append(entries, nacos.TransferConfigEntry{
|
||
DataID: item.DataID,
|
||
Group: item.Group,
|
||
Content: content,
|
||
Type: typ,
|
||
AppName: appName,
|
||
Desc: desc,
|
||
})
|
||
}
|
||
if pageNo >= page.PagesAvailable || len(page.PageItems) < pageSize {
|
||
break
|
||
}
|
||
pageNo++
|
||
// safety cap
|
||
if pageNo > 200 {
|
||
break
|
||
}
|
||
}
|
||
return entries, nil
|
||
}
|
||
|
||
func buildNacosImportPreview(
|
||
ctx context.Context,
|
||
client nacos.Client,
|
||
file, namespaceID string,
|
||
payload nacos.TransferFile,
|
||
) (nacosImportPreview, error) {
|
||
items := make([]nacosImportPreviewItem, 0, len(payload.Configs))
|
||
existsCount := 0
|
||
for index, cfg := range payload.Configs {
|
||
exists, err := nacosConfigExists(ctx, client, namespaceID, cfg.Group, cfg.DataID)
|
||
if err != nil {
|
||
return nacosImportPreview{}, err
|
||
}
|
||
if exists {
|
||
existsCount++
|
||
}
|
||
items = append(items, nacosImportPreviewItem{
|
||
Index: index,
|
||
DataID: cfg.DataID,
|
||
Group: cfg.Group,
|
||
Type: cfg.Type,
|
||
Exists: exists,
|
||
Selected: true,
|
||
})
|
||
}
|
||
return nacosImportPreview{
|
||
File: file,
|
||
ExportedAt: payload.ExportedAt,
|
||
NamespaceID: payload.NamespaceID,
|
||
SourceAppName: payload.SourceAppName,
|
||
Total: len(items),
|
||
ExistsCount: existsCount,
|
||
NewCount: len(items) - existsCount,
|
||
Items: items,
|
||
}, nil
|
||
}
|
||
|
||
func nacosConfigExists(ctx context.Context, client nacos.Client, namespaceID, group, dataID string) (bool, error) {
|
||
// Use short timeout so preview remains responsive.
|
||
probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||
defer cancel()
|
||
_, err := client.GetConfig(probeCtx, namespaceID, group, dataID)
|
||
if err == nil {
|
||
return true, nil
|
||
}
|
||
if nacos.IsConfigNotFound(err) {
|
||
return false, nil
|
||
}
|
||
if status, ok := nacos.HTTPStatusCode(err); ok {
|
||
if status == 404 {
|
||
return false, nil
|
||
}
|
||
return false, err
|
||
}
|
||
// Compatibility fallback for errors produced before structured status and
|
||
// not-found types were introduced. Match complete localized prefixes only.
|
||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||
if status, ok := explicitNacosHTTPStatus(msg); ok {
|
||
if status == 404 {
|
||
return false, nil
|
||
}
|
||
return false, err
|
||
}
|
||
for _, prefix := range []string{
|
||
"config not found:",
|
||
"配置不存在:",
|
||
"設定不存在:",
|
||
"設定が見つかりません:",
|
||
"konfiguration nicht gefunden:",
|
||
"конфигурация не найдена:",
|
||
} {
|
||
if strings.HasPrefix(msg, prefix) {
|
||
return false, nil
|
||
}
|
||
}
|
||
return false, err
|
||
}
|
||
|
||
func explicitNacosHTTPStatus(message string) (int, bool) {
|
||
markers := []string{
|
||
"nacos http",
|
||
"nacos-http-fehler",
|
||
"http nacos",
|
||
}
|
||
for _, marker := range markers {
|
||
index := strings.Index(message, marker)
|
||
if index < 0 {
|
||
continue
|
||
}
|
||
rest := message[index+len(marker):]
|
||
start := -1
|
||
for index, char := range rest {
|
||
if char >= '0' && char <= '9' {
|
||
start = index
|
||
break
|
||
}
|
||
}
|
||
if start < 0 {
|
||
return 0, false
|
||
}
|
||
end := start
|
||
for end < len(rest) && rest[end] >= '0' && rest[end] <= '9' {
|
||
end++
|
||
}
|
||
if end-start != 3 {
|
||
return 0, false
|
||
}
|
||
status, err := strconv.Atoi(rest[start:end])
|
||
if err != nil || status < 100 || status > 599 {
|
||
return 0, false
|
||
}
|
||
return status, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func normalizeNacosConfigIdentityKey(group, dataID string) (nacosConfigIdentityKey, bool) {
|
||
dataID = strings.TrimSpace(dataID)
|
||
if dataID == "" {
|
||
return nacosConfigIdentityKey{}, false
|
||
}
|
||
group = strings.TrimSpace(group)
|
||
if group == "" {
|
||
group = "DEFAULT_GROUP"
|
||
}
|
||
return nacosConfigIdentityKey{group: group, dataID: dataID}, true
|
||
}
|
||
|
||
func nacosImportSelectionMatchesPayload(
|
||
configs []nacos.TransferConfigEntry,
|
||
selectedByIndex map[int]nacosConfigIdentityKey,
|
||
selectedByIdentity map[nacosConfigIdentityKey]struct{},
|
||
) bool {
|
||
for index, selectedKey := range selectedByIndex {
|
||
if index < 0 || index >= len(configs) {
|
||
return false
|
||
}
|
||
payloadKey, ok := normalizeNacosConfigIdentityKey(configs[index].Group, configs[index].DataID)
|
||
if !ok || payloadKey != selectedKey {
|
||
return false
|
||
}
|
||
}
|
||
for selectedKey := range selectedByIdentity {
|
||
matched := false
|
||
for _, config := range configs {
|
||
payloadKey, ok := normalizeNacosConfigIdentityKey(config.Group, config.DataID)
|
||
if ok && payloadKey == selectedKey {
|
||
matched = true
|
||
break
|
||
}
|
||
}
|
||
if !matched {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func nacosImportRowSelected(
|
||
index int,
|
||
key nacosConfigIdentityKey,
|
||
selectedByIndex map[int]nacosConfigIdentityKey,
|
||
selectedByIdentity map[nacosConfigIdentityKey]struct{},
|
||
) bool {
|
||
if selectedKey, ok := selectedByIndex[index]; ok && selectedKey == key {
|
||
return true
|
||
}
|
||
_, ok := selectedByIdentity[key]
|
||
return ok
|
||
}
|
||
|
||
func (a *App) ensureNacosDataImportAllowed(config connection.ConnectionConfig) error {
|
||
if config.ReadOnly {
|
||
return errors.New(a.appText("nacos.backend.error.read_only", nil))
|
||
}
|
||
if config.Protection.RestrictDataImport {
|
||
return errors.New(readOnlyConnectionActionBlockedMessageWithText(
|
||
"connection.backend.action.import_data",
|
||
a.appText,
|
||
))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeNacosTransferFilename(filename string) string {
|
||
trimmed := strings.TrimSpace(filename)
|
||
if trimmed == "" {
|
||
return ""
|
||
}
|
||
if strings.EqualFold(filepath.Ext(trimmed), ".json") {
|
||
return trimmed
|
||
}
|
||
return trimmed + ".json"
|
||
}
|
||
|
||
func sanitizeNacosFilename(raw string) string {
|
||
text := strings.TrimSpace(raw)
|
||
if text == "" {
|
||
return "namespace"
|
||
}
|
||
replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "_")
|
||
return replacer.Replace(text)
|
||
}
|