Quellcode durchsuchen

feat(api): 改为批量获取全部网口配置并支持部分失败

将单接口查询接口升级为批量查询,客户端一次性加载所有配置。
新增 errors 字段以容忍部分接口读取失败,提升初始化鲁棒性。
yangkaixiang vor 1 Monat
Ursprung
Commit
0f9a9796e8

+ 34 - 9
docs/03-通信与HTTP_API.md

@@ -193,9 +193,9 @@ X-Admin-Password: Dieteng2026
 3. `requires_target_selection` 为 `true` 时,客户端应要求用户选择目标接口
 4. `name` 为逻辑展示标识,实际配置应使用 `system_name`
 
-### 3.4 获取指定接口当前配置
+### 3.4 获取全部接口当前配置
 
-`GET /api/network/config?interface=enp1s0`
+`GET /api/network/configs`
 
 响应:
 
@@ -204,20 +204,45 @@ X-Admin-Password: Dieteng2026
   "code": 0,
   "message": "成功",
   "data": {
-    "interface": "enp1s0",
-    "dhcp4": false,
-    "addresses": [
+    "configs": [
       {
-        "ip": "192.168.10.20",
-        "prefix": 24
+        "interface": "enp1s0",
+        "dhcp4": false,
+        "addresses": [
+          {
+            "ip": "192.168.10.20",
+            "prefix": 24
+          }
+        ],
+        "routes": [],
+        "dns": []
       }
     ],
-    "routes": [],
-    "dns": []
+    "errors": []
   }
 }
 ```
 
+部分失败响应:
+
+```json
+{
+  "code": 0,
+  "message": "部分接口配置读取失败",
+  "data": {
+    "configs": [],
+    "errors": [
+      {
+        "interface": "enp1s0",
+        "message": "读取 netplan 配置失败。"
+      }
+    ]
+  }
+}
+```
+
+说明:客户端应以 `configs[].interface` 匹配接口列表中的 `system_name`。`errors` 仅表示对应接口配置读取失败,不表示 HTTP 请求失败。
+
 ### 3.5 校验指定接口配置
 
 `POST /api/network/validate`

+ 1 - 1
docs/04-客户端流程与MVP.md

@@ -464,7 +464,7 @@
 1. 调用 `GET /api/health`
 2. 调用 `GET /api/device/info`
 3. 调用 `GET /api/network/interfaces`
-4. 调用 `GET /api/network/config?interface=<真实接口名>`
+4. 调用 `GET /api/network/configs` 一次性读取全部网口配置
 
 ### 2.3 配置目标接口
 

+ 2 - 1
docs/09-使用说明与故障处理.md

@@ -233,7 +233,8 @@ Dieteng2026
 | HTTP 健康检查返回状态码 `401` | 缺少密码或密码错误 | 重新输入正确密码 |
 | HTTP 健康检查返回状态码 `403` | 权限不足或 Server 拒绝执行对应操作 | 对配置保存、重启、关机类操作,确认 Server 使用 `sudo` 启动 |
 | 设备已连接,但暂时无法读取 Linux 网口列表 | Server 执行系统命令失败;系统权限不足;Linux 网络状态异常 | 确认 Server 以 `sudo` 启动;查看 Server 日志;在 Linux 上执行 `ip addr` 检查网口 |
-| 读取目标网口配置失败 | 接口不存在;读取 netplan 失败;Server 内部错误 | 点击“重新获取”;确认真实接口名;查看 Server 日志和 `/etc/netplan` 文件 |
+| 读取全部网口配置失败 | Server 不支持批量读取接口;HTTP 连接失败;读取网口列表失败 | 确认 Server 已更新并运行;检查端口和网络连接;查看 Server 日志 |
+| 读取目标网口配置失败 | 批量读取中该接口读取 netplan 失败;Server 内部错误 | 点击“重新获取”;查看 Server 日志和 `/etc/netplan` 文件 |
 | 网口配置不能为空 | 客户端没有读取到任何可提交的网口配置 | 点击“重新获取”;确认 Server 可读取 Linux 网口列表 |
 | IP 地址不能为空 | 静态模式下没有填写 IP | 填写 IP 地址,或勾选 DHCP |
 | 子网掩码格式不正确 | 子网掩码不是合法掩码 | 使用 `255.255.255.0`、`255.255.0.0` 等合法掩码 |

+ 1 - 1
server/internal/config/config.go

@@ -6,7 +6,7 @@ import (
 	"net"
 )
 
-const ServerVersion = "2026.05.14.1808"
+const ServerVersion = "2026.05.14.1904"
 
 type Config struct {
 	HTTPHost         string

+ 22 - 13
server/internal/httpserver/server.go

@@ -57,7 +57,7 @@ func (s *Server) Run(ctx context.Context) error {
 	mux.Handle("/api/health", auth.Middleware(s.cfg, http.HandlerFunc(s.handleHealth)))
 	mux.Handle("/api/device/info", auth.Middleware(s.cfg, http.HandlerFunc(s.handleDeviceInfo)))
 	mux.Handle("/api/network/interfaces", auth.Middleware(s.cfg, http.HandlerFunc(s.handleInterfaces)))
-	mux.Handle("/api/network/config", auth.Middleware(s.cfg, http.HandlerFunc(s.handleConfig)))
+	mux.Handle("/api/network/configs", auth.Middleware(s.cfg, http.HandlerFunc(s.handleConfigs)))
 	mux.Handle("/api/network/validate", auth.Middleware(s.cfg, http.HandlerFunc(s.handleValidate)))
 	mux.Handle("/api/network/validate-all", auth.Middleware(s.cfg, http.HandlerFunc(s.handleValidateAll)))
 	mux.Handle("/api/network/apply", auth.Middleware(s.cfg, http.HandlerFunc(s.handleApply)))
@@ -179,22 +179,31 @@ func (s *Server) handleInterfaces(w http.ResponseWriter, _ *http.Request) {
 	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "成功", Data: data})
 }
 
-func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
-	interfaceName := r.URL.Query().Get("interface")
-	if interfaceName == "" {
-		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"缺少 interface 参数。"}}})
-		return
-	}
-	if !s.interfaceExists(interfaceName) {
-		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"目标接口不存在。"}}})
-		return
-	}
-	data, err := s.configSvc.Read(interfaceName)
+func (s *Server) handleConfigs(w http.ResponseWriter, _ *http.Request) {
+	interfaces, err := s.interfaceSvc.List()
 	if err != nil {
 		writeJSON(w, http.StatusInternalServerError, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string]string{"error": err.Error()}})
 		return
 	}
-	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "成功", Data: data})
+
+	result := model.InterfaceConfigsResponse{
+		Configs: []model.InterfaceConfig{},
+		Errors:  []model.InterfaceConfigError{},
+	}
+	for _, item := range interfaces.Interfaces {
+		data, err := s.configSvc.Read(item.SystemName)
+		if err != nil {
+			result.Errors = append(result.Errors, model.InterfaceConfigError{Interface: item.SystemName, Message: err.Error()})
+			continue
+		}
+		result.Configs = append(result.Configs, data)
+	}
+
+	message := "成功"
+	if len(result.Errors) > 0 {
+		message = "部分接口配置读取失败"
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: message, Data: result})
 }
 
 func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {

+ 10 - 0
server/internal/model/types.go

@@ -75,6 +75,16 @@ type InterfaceConfigsRequest struct {
 	Configs []InterfaceConfig `json:"configs"`
 }
 
+type InterfaceConfigsResponse struct {
+	Configs []InterfaceConfig      `json:"configs"`
+	Errors  []InterfaceConfigError `json:"errors,omitempty"`
+}
+
+type InterfaceConfigError struct {
+	Interface string `json:"interface"`
+	Message   string `json:"message"`
+}
+
 type InterfaceAddressConfig struct {
 	IP     string `json:"ip"`
 	Prefix int    `json:"prefix"`

+ 97 - 49
windows/NetworkTool.Client/DeviceDetailsWindow.xaml.cs

@@ -27,6 +27,8 @@ public partial class DeviceDetailsWindow : Window
     private bool _suppressConfigChangeHandling;
     private CancellationTokenSource? _statusMessageCts;
 
+    private readonly record struct ConfigLoadOutcome(bool RequestSucceeded, int FailedCount);
+
     public DeviceDetailsWindow(string baseAddress, string localIPv4, string password)
     {
         InitializeComponent();
@@ -75,13 +77,26 @@ public partial class DeviceDetailsWindow : Window
         {
             var editor = new InterfaceEditor(interfaces.Interfaces[i], i + 1);
             _interfaces.Add(editor);
-            await LoadRemoteInterfaceConfigAsync(editor);
         }
 
+        var configLoadOutcome = await LoadRemoteInterfaceConfigsAsync();
+
         _configValidated = false;
         _configDirty = false;
-        SetConfigStateMessage("已读取全部网口配置。", false);
-        ShowStatusMessage("已读取全部网口配置。", StatusMessageType.Success);
+        if (!configLoadOutcome.RequestSucceeded)
+        {
+            SetConfigStateMessage("读取全部网口配置失败。", true);
+        }
+        else if (configLoadOutcome.FailedCount > 0)
+        {
+            SetConfigStateMessage($"已读取部分网口配置,{configLoadOutcome.FailedCount} 个网口读取失败。", true);
+            ShowStatusMessage($"已读取部分网口配置,{configLoadOutcome.FailedCount} 个网口读取失败。", StatusMessageType.Warning);
+        }
+        else
+        {
+            SetConfigStateMessage("已读取全部网口配置。", false);
+            ShowStatusMessage("已读取全部网口配置。", StatusMessageType.Success);
+        }
     }
 
     private void ClearDetails()
@@ -104,57 +119,42 @@ public partial class DeviceDetailsWindow : Window
         return Uri.TryCreate(baseAddress, UriKind.Absolute, out var uri) ? uri.Host : baseAddress;
     }
 
-    private async Task LoadRemoteInterfaceConfigAsync(InterfaceEditor editor, bool useBusyState = false)
+    private async Task<ConfigLoadOutcome> LoadRemoteInterfaceConfigsAsync(bool useBusyState = false)
     {
         if (useBusyState)
         {
-            SetBusyState(true, "正在读取 Linux 端 IP 配置...");
+            SetBusyState(true, "正在读取全部网口配置...");
         }
 
         try
         {
-            var result = await _serverApiService.GetInterfaceConfigAsync(_baseAddress, _password, _localIPv4, editor.SystemName);
+            var result = await _serverApiService.GetInterfaceConfigsAsync(_baseAddress, _password, _localIPv4);
             if (!result.Success || result.Data is null)
             {
-                ShowStatusMessage($"读取目标网口 {editor.DisplayLabel} 配置失败:{result.Message}", StatusMessageType.Error);
-                return;
+                ShowStatusMessage($"读取全部网口配置失败:{result.Message}", StatusMessageType.Error);
+                return new ConfigLoadOutcome(false, _interfaces.Count);
             }
 
-            var config = result.Data;
-            _suppressConfigChangeHandling = true;
-            editor.Dhcp4 = config.Dhcp4;
-            editor.Addresses.Clear();
-            foreach (var address in config.EffectiveAddresses)
-            {
-                editor.Addresses.Add(new EditableAddress(editor) { IP = address.IP, Mask = PrefixToMask(address.Prefix) });
-            }
-            editor.Routes.Clear();
-            editor.DefaultGatewayEnabled = false;
-            editor.DefaultGateway = string.Empty;
-            foreach (var route in config.EffectiveRoutes)
-            {
-                if (route.To.Equals("default", StringComparison.OrdinalIgnoreCase))
-                {
-                    editor.DefaultGatewayEnabled = true;
-                    editor.DefaultGateway = route.Via;
-                }
-                else
-                {
-                    editor.Routes.Add(CreateEditableRoute(editor, route));
-                }
-            }
-            editor.CustomRoutesEnabled = editor.Routes.Count > 0;
-            editor.Dns.Clear();
-            if (config.Dns is not null)
+            var configsByInterface = result.Data.Configs.ToDictionary(config => config.Interface, StringComparer.OrdinalIgnoreCase);
+            var errorsByInterface = result.Data.Errors.ToDictionary(error => error.Interface, StringComparer.OrdinalIgnoreCase);
+            var failedConfigCount = 0;
+            foreach (var editor in _interfaces)
             {
-                foreach (var dns in config.Dns)
+                if (configsByInterface.TryGetValue(editor.SystemName, out var config))
                 {
-                    editor.Dns.Add(new EditableDns(editor) { Address = dns });
+                    ApplyRemoteInterfaceConfig(editor, config);
+                    continue;
                 }
+
+                failedConfigCount++;
+                var message = errorsByInterface.TryGetValue(editor.SystemName, out var error)
+                    ? error.Message
+                    : "Server 未返回该网口配置。";
+                ShowStatusMessage($"读取目标网口 {editor.DisplayLabel} 配置失败:{message}", StatusMessageType.Error);
             }
-            editor.CaptureOriginalConfiguration();
-            _suppressConfigChangeHandling = false;
+
             UpdateButtonStates();
+            return new ConfigLoadOutcome(true, failedConfigCount);
         }
         finally
         {
@@ -166,6 +166,62 @@ public partial class DeviceDetailsWindow : Window
         }
     }
 
+    private void ApplyRemoteInterfaceConfig(InterfaceEditor editor, RemoteInterfaceConfig config)
+    {
+        _suppressConfigChangeHandling = true;
+        editor.Dhcp4 = config.Dhcp4;
+        editor.Addresses.Clear();
+        foreach (var address in config.EffectiveAddresses)
+        {
+            editor.Addresses.Add(new EditableAddress(editor) { IP = address.IP, Mask = PrefixToMask(address.Prefix) });
+        }
+        editor.Routes.Clear();
+        editor.DefaultGatewayEnabled = false;
+        editor.DefaultGateway = string.Empty;
+        foreach (var route in config.EffectiveRoutes)
+        {
+            if (route.To.Equals("default", StringComparison.OrdinalIgnoreCase))
+            {
+                editor.DefaultGatewayEnabled = true;
+                editor.DefaultGateway = route.Via;
+            }
+            else
+            {
+                editor.Routes.Add(CreateEditableRoute(editor, route));
+            }
+        }
+        editor.CustomRoutesEnabled = editor.Routes.Count > 0;
+        editor.Dns.Clear();
+        if (config.Dns is not null)
+        {
+            foreach (var dns in config.Dns)
+            {
+                editor.Dns.Add(new EditableDns(editor) { Address = dns });
+            }
+        }
+        editor.CaptureOriginalConfiguration();
+        _suppressConfigChangeHandling = false;
+    }
+
+    private void ShowConfigLoadStatus(ConfigLoadOutcome outcome, string successMessage, string partialMessagePrefix, string failureMessage)
+    {
+        if (!outcome.RequestSucceeded)
+        {
+            SetConfigStateMessage(failureMessage, true);
+        }
+        else if (outcome.FailedCount > 0)
+        {
+            var message = $"{partialMessagePrefix},{outcome.FailedCount} 个网口读取失败。";
+            SetConfigStateMessage(message, true);
+            ShowStatusMessage(message, StatusMessageType.Warning);
+        }
+        else
+        {
+            SetConfigStateMessage(successMessage, false);
+            ShowStatusMessage(successMessage, StatusMessageType.Success);
+        }
+    }
+
     private async void ReloadInterfaceConfigButton_OnClick(object sender, RoutedEventArgs e)
     {
         if (!ConfirmDiscardPendingChanges("当前配置已修改但尚未保存,重新获取会丢失未保存内容。是否继续重新获取?", "确认重新获取配置"))
@@ -176,15 +232,10 @@ public partial class DeviceDetailsWindow : Window
         SetBusyState(true, "正在重新获取全部网口配置...");
         try
         {
-            foreach (var editor in _interfaces)
-            {
-                await LoadRemoteInterfaceConfigAsync(editor);
-            }
-
+            var configLoadOutcome = await LoadRemoteInterfaceConfigsAsync();
             _configValidated = false;
             _configDirty = false;
-            SetConfigStateMessage("已重新获取全部网口配置。", false);
-            ShowStatusMessage("已重新获取全部网口配置。", StatusMessageType.Success);
+            ShowConfigLoadStatus(configLoadOutcome, "已重新获取全部网口配置。", "已重新获取部分网口配置", "重新获取全部网口配置失败。");
         }
         finally
         {
@@ -334,10 +385,7 @@ public partial class DeviceDetailsWindow : Window
                     _configValidated = false;
                     _configDirty = false;
                     SetConfigStateMessage("配置已保存,当前显示为设备最新配置。", false);
-                    foreach (var editor in _interfaces)
-                    {
-                        await LoadRemoteInterfaceConfigAsync(editor);
-                    }
+                    await LoadRemoteInterfaceConfigsAsync();
                 }
 
                 ShowTaskCompletionDialog(task);

+ 18 - 0
windows/NetworkTool.Client/Models/RemoteInterfaceConfig.cs

@@ -65,3 +65,21 @@ public sealed class RemoteInterfaceConfigsRequest
     [JsonPropertyName("configs")]
     public IReadOnlyList<RemoteInterfaceConfig> Configs { get; init; } = [];
 }
+
+public sealed class RemoteInterfaceConfigsResponse
+{
+    [JsonPropertyName("configs")]
+    public IReadOnlyList<RemoteInterfaceConfig> Configs { get; init; } = [];
+
+    [JsonPropertyName("errors")]
+    public IReadOnlyList<RemoteInterfaceConfigError> Errors { get; init; } = [];
+}
+
+public sealed class RemoteInterfaceConfigError
+{
+    [JsonPropertyName("interface")]
+    public string Interface { get; init; } = string.Empty;
+
+    [JsonPropertyName("message")]
+    public string Message { get; init; } = string.Empty;
+}

+ 1 - 1
windows/NetworkTool.Client/NetworkTool.Client.csproj

@@ -6,7 +6,7 @@
     <Nullable>enable</Nullable>
     <ImplicitUsings>enable</ImplicitUsings>
     <UseWPF>true</UseWPF>
-    <InformationalVersion>2026.05.14.1808</InformationalVersion>
+    <InformationalVersion>2026.05.14.1904</InformationalVersion>
   </PropertyGroup>
 
 </Project>

+ 12 - 16
windows/NetworkTool.Client/Services/ServerApiService.cs

@@ -96,44 +96,40 @@ public sealed class ServerApiService
         }
     }
 
-    public async Task<ApiCallResult<RemoteInterfaceConfig>> GetInterfaceConfigAsync(string baseAddress, string password, string localIPv4, string interfaceName, CancellationToken cancellationToken = default)
+    public async Task<ApiCallResult<RemoteInterfaceConfigsResponse>> GetInterfaceConfigsAsync(string baseAddress, string password, string localIPv4, CancellationToken cancellationToken = default)
     {
         try
         {
             using var client = CreateClient(baseAddress, password, localIPv4);
-            using var response = await client.GetAsync($"/api/network/config?interface={Uri.EscapeDataString(interfaceName)}", cancellationToken);
-            if (!response.IsSuccessStatusCode)
+            using var response = await client.GetAsync("/api/network/configs", cancellationToken);
+            var content = await response.Content.ReadAsStringAsync(cancellationToken);
+            var wrapper = DeserializeEnvelope<RemoteInterfaceConfigsResponse>(content);
+            if (wrapper is null)
             {
-                return new ApiCallResult<RemoteInterfaceConfig>
-                {
-                    Success = false,
-                    StatusCode = (int)response.StatusCode,
-                    Message = $"读取接口配置失败,HTTP 状态码 {(int)response.StatusCode}。",
-                };
+                return CreateInvalidJsonResult<RemoteInterfaceConfigsResponse>(response.StatusCode, content, "批量读取配置");
             }
 
-            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
-            var wrapper = await JsonSerializer.DeserializeAsync<ApiEnvelope<RemoteInterfaceConfig>>(stream, _jsonOptions, cancellationToken);
             if (wrapper?.Data is null)
             {
-                return new ApiCallResult<RemoteInterfaceConfig>
+                return new ApiCallResult<RemoteInterfaceConfigsResponse>
                 {
                     Success = false,
+                    StatusCode = (int)response.StatusCode,
                     Message = "接口配置返回内容为空。",
                 };
             }
 
-            return new ApiCallResult<RemoteInterfaceConfig>
+            return new ApiCallResult<RemoteInterfaceConfigsResponse>
             {
-                Success = true,
+                Success = response.IsSuccessStatusCode,
                 StatusCode = (int)response.StatusCode,
-                Message = "成功",
+                Message = wrapper.Message,
                 Data = wrapper.Data,
             };
         }
         catch (Exception ex)
         {
-            return new ApiCallResult<RemoteInterfaceConfig>
+            return new ApiCallResult<RemoteInterfaceConfigsResponse>
             {
                 Success = false,
                 Message = ex.Message,