✨ feat(*): 添加 TCP 扫码枪连接测试功能及相关日志记录

This commit is contained in:
2026-06-10 09:28:27 +08:00
parent 17839a9e76
commit 404ebadbe7
3 changed files with 358 additions and 8 deletions

View File

@@ -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."

View File

@@ -76,10 +76,19 @@ public sealed class TcpDualScannerService : IScannerService
/// <returns>两把扫码枪均可连接返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken)
{
Task<bool> first = TestEndpointAsync(_options.Scanner1, cancellationToken);
Task<bool> 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;
}
/// <summary>
@@ -171,15 +180,35 @@ public sealed class TcpDualScannerService : IScannerService
/// <returns>可连接返回 <see langword="true"/>。</returns>
private async Task<bool> 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
/// <returns>表示连接完成的任务。</returns>
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);
}
}
/// <summary>

View File

@@ -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;
/// <summary>
/// 验证 TCP 双扫码枪服务的连接探活行为。
/// </summary>
public sealed class TcpDualScannerServiceTests
{
/// <summary>
/// 连接测试成功时,日志应包含本机出口和远端地址,便于和部署机脚本结果对照。
/// </summary>
/// <returns>异步测试任务。</returns>
[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<TcpClient> firstAccept = firstListener.AcceptTcpClientAsync();
Task<TcpClient> secondAccept = secondListener.AcceptTcpClientAsync();
var logger = new FakeAppLogger<TcpDualScannerService>();
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"));
}
/// <summary>
/// 连接测试失败时,日志应包含实际读取到的目标地址和端口,便于排查部署配置差异。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task TestConnectionAsync_ShouldLogEndpointAddress_WhenConnectFails()
{
var logger = new FakeAppLogger<TcpDualScannerService>();
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));
}
/// <summary>
/// 连接超时时,探活应返回离线结果,而不是向启动流程传播取消异常。
/// </summary>
/// <returns>异步测试任务。</returns>
[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<TcpDualScannerService>());
bool connected = await service.TestConnectionAsync(CancellationToken.None);
Assert.False(connected);
}
/// <summary>
/// 调用方主动取消时,探活应继续向上层传播取消,便于应用关闭流程及时收尾。
/// </summary>
/// <returns>异步测试任务。</returns>
[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<TcpDualScannerService>());
using var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.TestConnectionAsync(cancellationTokenSource.Token));
}
private sealed class FakeAppLogger<TCategoryName> : AxiOmron.PcbCheck.Services.Interfaces.IAppLogger<TCategoryName>
{
public List<LogEntry> InformationEntries { get; } = new();
public List<LogEntry> 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);
}