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);
+}