From 404ebadbe72b09cdde67f51e77fc1909db982b80 Mon Sep 17 00:00:00 2001 From: "yunxiao.zhu" Date: Wed, 10 Jun 2026 09:28:27 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(*):=20=E6=B7=BB=E5=8A=A0=20TCP?= =?UTF-8?q?=20=E6=89=AB=E7=A0=81=E6=9E=AA=E8=BF=9E=E6=8E=A5=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8=E5=85=B3=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/Test-ScannerTcpConnection.ps1 | 119 +++++++++++ .../Implementations/TcpDualScannerService.cs | 53 ++++- .../TcpDualScannerServiceTests.cs | 194 ++++++++++++++++++ 3 files changed, 358 insertions(+), 8 deletions(-) create mode 100644 scripts/Test-ScannerTcpConnection.ps1 create mode 100644 tests/AxiOmron.PcbCheck.Tests/TcpDualScannerServiceTests.cs diff --git a/scripts/Test-ScannerTcpConnection.ps1 b/scripts/Test-ScannerTcpConnection.ps1 new file mode 100644 index 0000000..1755b95 --- /dev/null +++ b/scripts/Test-ScannerTcpConnection.ps1 @@ -0,0 +1,119 @@ +param( + [string]$Scanner1Host = "192.168.1.13", + [int]$Scanner1Port = 2001, + [string]$Scanner2Host = "192.168.1.14", + [int]$Scanner2Port = 2001, + [int]$TimeoutMs = 5000, + [switch]$Parallel +) + +$ErrorActionPreference = "Stop" + +function Test-TcpClientConnection { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$HostName, + + [Parameter(Mandatory = $true)] + [int]$Port, + + [Parameter(Mandatory = $true)] + [int]$ConnectTimeoutMs + ) + + $client = [System.Net.Sockets.TcpClient]::new() + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + Write-Host "[$Name] Connecting to $HostName`:$Port, TimeoutMs=$ConnectTimeoutMs ..." + $connectTask = $client.ConnectAsync($HostName, $Port) + $timeoutTask = [System.Threading.Tasks.Task]::Delay($ConnectTimeoutMs) + $completedTask = [System.Threading.Tasks.Task]::WhenAny($connectTask, $timeoutTask).GetAwaiter().GetResult() + $stopwatch.Stop() + + if ($completedTask -ne $connectTask) { + return [pscustomobject]@{ + Name = $Name + Target = "$HostName`:$Port" + Succeeded = $false + ElapsedMs = $stopwatch.ElapsedMilliseconds + LocalEndPoint = "" + RemoteEndPoint = "" + ErrorType = "Timeout" + ErrorMessage = "ConnectAsync did not complete within $ConnectTimeoutMs ms." + } + } + + if ($connectTask.IsFaulted) { + $exception = $connectTask.Exception.GetBaseException() + return [pscustomobject]@{ + Name = $Name + Target = "$HostName`:$Port" + Succeeded = $false + ElapsedMs = $stopwatch.ElapsedMilliseconds + LocalEndPoint = "" + RemoteEndPoint = "" + ErrorType = $exception.GetType().FullName + ErrorMessage = $exception.Message + } + } + + return [pscustomobject]@{ + Name = $Name + Target = "$HostName`:$Port" + Succeeded = $client.Connected + ElapsedMs = $stopwatch.ElapsedMilliseconds + LocalEndPoint = $client.Client.LocalEndPoint.ToString() + RemoteEndPoint = $client.Client.RemoteEndPoint.ToString() + ErrorType = "" + ErrorMessage = "" + } + } + catch { + $stopwatch.Stop() + return [pscustomobject]@{ + Name = $Name + Target = "$HostName`:$Port" + Succeeded = $false + ElapsedMs = $stopwatch.ElapsedMilliseconds + LocalEndPoint = "" + RemoteEndPoint = "" + ErrorType = $_.Exception.GetType().FullName + ErrorMessage = $_.Exception.Message + } + } + finally { + $client.Dispose() + } +} + +Write-Host "=== Scanner TCP Client Diagnostic ===" +Write-Host "Machine: $env:COMPUTERNAME" +Write-Host "User: $env:USERNAME" +Write-Host "Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')" +Write-Host "Mode: $(if ($Parallel) { 'Parallel' } else { 'Sequential' })" +Write-Host "" + +if ($Parallel) { + $job1 = Start-Job -ScriptBlock ${function:Test-TcpClientConnection} -ArgumentList "Scanner1", $Scanner1Host, $Scanner1Port, $TimeoutMs + $job2 = Start-Job -ScriptBlock ${function:Test-TcpClientConnection} -ArgumentList "Scanner2", $Scanner2Host, $Scanner2Port, $TimeoutMs + $results = Receive-Job -Job $job1, $job2 -Wait + Remove-Job -Job $job1, $job2 +} +else { + $results = @( + Test-TcpClientConnection -Name "Scanner1" -HostName $Scanner1Host -Port $Scanner1Port -ConnectTimeoutMs $TimeoutMs + Test-TcpClientConnection -Name "Scanner2" -HostName $Scanner2Host -Port $Scanner2Port -ConnectTimeoutMs $TimeoutMs + ) +} + +foreach ($result in $results) { + Write-Host "--- $($result.Name) ---" + $result | Format-List +} + +Write-Host "" +Write-Host "Tip: if Sequential succeeds but Parallel times out, the device or network may reject concurrent short connections." +Write-Host "Tip: close TCP debugging tools before running this script, because some scanners allow only one client connection." diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/TcpDualScannerService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/TcpDualScannerService.cs index 684c5d0..54722bf 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/TcpDualScannerService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/TcpDualScannerService.cs @@ -76,10 +76,19 @@ public sealed class TcpDualScannerService : IScannerService /// 两把扫码枪均可连接返回 ;否则返回 。 public async Task TestConnectionAsync(CancellationToken cancellationToken) { - Task first = TestEndpointAsync(_options.Scanner1, cancellationToken); - Task second = TestEndpointAsync(_options.Scanner2, cancellationToken); - bool[] results = await Task.WhenAll(first, second).ConfigureAwait(false); - return results.All(static result => result); + _logger.LogInformation( + "开始测试 TCP 扫码枪连接(串行): {Scanner1Name}={Scanner1Host}:{Scanner1Port}, {Scanner2Name}={Scanner2Host}:{Scanner2Port}, ConnectTimeoutMs={ConnectTimeoutMs}", + false, + _options.Scanner1.Name, + _options.Scanner1.Host, + _options.Scanner1.Port, + _options.Scanner2.Name, + _options.Scanner2.Host, + _options.Scanner2.Port, + _options.ConnectTimeoutMs); + bool first = await TestEndpointAsync(_options.Scanner1, cancellationToken).ConfigureAwait(false); + bool second = await TestEndpointAsync(_options.Scanner2, cancellationToken).ConfigureAwait(false); + return first && second; } /// @@ -171,15 +180,35 @@ public sealed class TcpDualScannerService : IScannerService /// 可连接返回 。 private async Task TestEndpointAsync(TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken) { + var stopwatch = Stopwatch.StartNew(); try { using var client = new TcpClient(); await ConnectAsync(client, endpoint, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + _logger.LogInformation( + "{ScannerName} TCP 连接测试成功,目标={Host}:{Port}, LocalEndPoint={LocalEndPoint}, RemoteEndPoint={RemoteEndPoint}, ElapsedMs={ElapsedMs}", + false, + endpoint.Name, + endpoint.Host, + endpoint.Port, + client.Client.LocalEndPoint?.ToString() ?? string.Empty, + client.Client.RemoteEndPoint?.ToString() ?? string.Empty, + stopwatch.ElapsedMilliseconds); return client.Connected; } catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogWarning(ex, "{ScannerName} TCP 连接测试失败", false, endpoint.Name); + stopwatch.Stop(); + _logger.LogWarning( + ex, + "{ScannerName} TCP 连接测试失败,目标={Host}:{Port}, ConnectTimeoutMs={ConnectTimeoutMs}, ElapsedMs={ElapsedMs}", + false, + endpoint.Name, + endpoint.Host, + endpoint.Port, + _options.ConnectTimeoutMs, + stopwatch.ElapsedMilliseconds); return false; } } @@ -193,9 +222,17 @@ public sealed class TcpDualScannerService : IScannerService /// 表示连接完成的任务。 private async Task ConnectAsync(TcpClient client, TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs)); - await client.ConnectAsync(endpoint.Host, endpoint.Port, timeoutCts.Token).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + try + { + await client.ConnectAsync(endpoint.Host, endpoint.Port) + .WaitAsync(TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs), cancellationToken) + .ConfigureAwait(false); + } + catch (TimeoutException ex) + { + throw new TimeoutException($"{endpoint.Name} TCP 连接超时,目标={endpoint.Host}:{endpoint.Port}。", ex); + } } /// diff --git a/tests/AxiOmron.PcbCheck.Tests/TcpDualScannerServiceTests.cs b/tests/AxiOmron.PcbCheck.Tests/TcpDualScannerServiceTests.cs new file mode 100644 index 0000000..5681b46 --- /dev/null +++ b/tests/AxiOmron.PcbCheck.Tests/TcpDualScannerServiceTests.cs @@ -0,0 +1,194 @@ +using System.Net; +using System.Net.Sockets; +using AxiOmron.PcbCheck.Options; +using AxiOmron.PcbCheck.Services.Implementations; +using Xunit; + +namespace AxiOmron.PcbCheck.Tests; + +/// +/// 验证 TCP 双扫码枪服务的连接探活行为。 +/// +public sealed class TcpDualScannerServiceTests +{ + /// + /// 连接测试成功时,日志应包含本机出口和远端地址,便于和部署机脚本结果对照。 + /// + /// 异步测试任务。 + [Fact] + public async Task TestConnectionAsync_ShouldLogLocalAndRemoteEndpoint_WhenConnectSucceeds() + { + using var firstListener = new TcpListener(IPAddress.Loopback, 0); + using var secondListener = new TcpListener(IPAddress.Loopback, 0); + firstListener.Start(); + secondListener.Start(); + Task firstAccept = firstListener.AcceptTcpClientAsync(); + Task secondAccept = secondListener.AcceptTcpClientAsync(); + var logger = new FakeAppLogger(); + var service = new TcpDualScannerService( + new AppConfig + { + Scanner = new ScannerOptions + { + Scanner1 = new TcpScannerEndpointOptions + { + Name = "扫码枪1", + Host = IPAddress.Loopback.ToString(), + Port = ((IPEndPoint)firstListener.LocalEndpoint).Port + }, + Scanner2 = new TcpScannerEndpointOptions + { + Name = "扫码枪2", + Host = IPAddress.Loopback.ToString(), + Port = ((IPEndPoint)secondListener.LocalEndpoint).Port + }, + ConnectTimeoutMs = 5000 + } + }, + logger); + + bool connected = await service.TestConnectionAsync(CancellationToken.None); + using TcpClient firstAcceptedClient = await firstAccept; + using TcpClient secondAcceptedClient = await secondAccept; + + Assert.True(connected); + Assert.Contains(logger.InformationEntries, entry => entry.Message.Contains("LocalEndPoint={LocalEndPoint}", StringComparison.Ordinal) + && entry.Message.Contains("RemoteEndPoint={RemoteEndPoint}", StringComparison.Ordinal) + && entry.Args.Contains("扫码枪1")); + Assert.Contains(logger.InformationEntries, entry => entry.Message.Contains("LocalEndPoint={LocalEndPoint}", StringComparison.Ordinal) + && entry.Message.Contains("RemoteEndPoint={RemoteEndPoint}", StringComparison.Ordinal) + && entry.Args.Contains("扫码枪2")); + } + + /// + /// 连接测试失败时,日志应包含实际读取到的目标地址和端口,便于排查部署配置差异。 + /// + /// 异步测试任务。 + [Fact] + public async Task TestConnectionAsync_ShouldLogEndpointAddress_WhenConnectFails() + { + var logger = new FakeAppLogger(); + var service = new TcpDualScannerService( + new AppConfig + { + Scanner = new ScannerOptions + { + Scanner1 = new TcpScannerEndpointOptions + { + Name = "扫码枪1", + Host = "192.0.2.10", + Port = 2001 + }, + Scanner2 = new TcpScannerEndpointOptions + { + Name = "扫码枪2", + Host = "192.0.2.11", + Port = 2002 + }, + ConnectTimeoutMs = 1 + } + }, + logger); + + await service.TestConnectionAsync(CancellationToken.None); + + Assert.Contains(logger.WarningEntries, entry => entry.Message.Contains("{Host}:{Port}", StringComparison.Ordinal) + && entry.Args.Contains("192.0.2.10") + && entry.Args.Contains(2001)); + Assert.Contains(logger.WarningEntries, entry => entry.Message.Contains("{Host}:{Port}", StringComparison.Ordinal) + && entry.Args.Contains("192.0.2.11") + && entry.Args.Contains(2002)); + } + + /// + /// 连接超时时,探活应返回离线结果,而不是向启动流程传播取消异常。 + /// + /// 异步测试任务。 + [Fact] + public async Task TestConnectionAsync_ShouldReturnFalse_WhenConnectTimesOut() + { + var service = new TcpDualScannerService( + new AppConfig + { + Scanner = new ScannerOptions + { + Scanner1 = new TcpScannerEndpointOptions + { + Name = "扫码枪1", + Host = "192.0.2.1", + Port = 2001 + }, + Scanner2 = new TcpScannerEndpointOptions + { + Name = "扫码枪2", + Host = "192.0.2.2", + Port = 2001 + }, + ConnectTimeoutMs = 1 + } + }, + new FakeAppLogger()); + + bool connected = await service.TestConnectionAsync(CancellationToken.None); + + Assert.False(connected); + } + + /// + /// 调用方主动取消时,探活应继续向上层传播取消,便于应用关闭流程及时收尾。 + /// + /// 异步测试任务。 + [Fact] + public async Task TestConnectionAsync_ShouldThrow_WhenCallerCancels() + { + var service = new TcpDualScannerService( + new AppConfig + { + Scanner = new ScannerOptions + { + Scanner1 = new TcpScannerEndpointOptions + { + Name = "扫码枪1", + Host = "192.0.2.1", + Port = 2001 + }, + Scanner2 = new TcpScannerEndpointOptions + { + Name = "扫码枪2", + Host = "192.0.2.2", + Port = 2001 + }, + ConnectTimeoutMs = 3000 + } + }, + new FakeAppLogger()); + + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync( + () => service.TestConnectionAsync(cancellationTokenSource.Token)); + } + + private sealed class FakeAppLogger : AxiOmron.PcbCheck.Services.Interfaces.IAppLogger + { + public List InformationEntries { get; } = new(); + + public List WarningEntries { get; } = new(); + + public void LogError(string message, bool showInUi = false, params object?[] args) { } + public void LogError(Exception exception, string message, bool showInUi = false, params object?[] args) { } + public void LogInformation(string message, bool showInUi = false, params object?[] args) + { + InformationEntries.Add(new LogEntry(message, args)); + } + + public void LogWarning(string message, bool showInUi = false, params object?[] args) { } + public void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args) + { + WarningEntries.Add(new LogEntry(message, args)); + } + } + + private sealed record LogEntry(string Message, object?[] Args); +}