✨ feat(*): 添加管理员解锁与 SFTP 探活能力
* 新增管理员解锁弹窗、手动操作权限控制与倒计时状态 * 支持系统设置页测试 SFTP 连接,并在启动时执行探活 * 补充设计时服务、全局异常兜底与相关单元测试
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||
using AxiOmron.PcbCheck.Views.Windows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AxiOmron.PcbCheck.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// 提供管理员密码输入弹窗显示能力。
|
||||
/// </summary>
|
||||
public sealed class AdminUnlockDialogService : IAdminUnlockDialogService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化管理员密码弹窗服务。
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">服务提供器。</param>
|
||||
public AdminUnlockDialogService(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示管理员密码输入弹窗并校验是否通过。
|
||||
/// </summary>
|
||||
/// <param name="expectedPassword">期望密码。</param>
|
||||
/// <param name="sessionTimeout">本次解锁会话持续时间。</param>
|
||||
/// <returns>输入正确返回 <see langword="true"/>;取消或错误返回 <see langword="false"/>。</returns>
|
||||
public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(expectedPassword);
|
||||
|
||||
var dialog = _serviceProvider.GetRequiredService<AdminUnlockDialog>();
|
||||
|
||||
if (System.Windows.Application.Current?.MainWindow is { } mainWindow)
|
||||
{
|
||||
dialog.Owner = mainWindow;
|
||||
}
|
||||
|
||||
dialog.Configure(expectedPassword, sessionTimeout);
|
||||
return dialog.ShowDialog() == true;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Web;
|
||||
using AxiOmron.PcbCheck.Models;
|
||||
using AxiOmron.PcbCheck.Options;
|
||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||
@@ -52,20 +52,8 @@ public sealed class AndonService : IAndonService
|
||||
{
|
||||
using var client = _httpClientFactory.CreateClient(nameof(AndonService));
|
||||
client.Timeout = TimeSpan.FromMilliseconds(_options.TimeoutMs);
|
||||
using var message = new HttpRequestMessage(new HttpMethod(_options.Method), _options.Url)
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
stationCode = _options.StationCode,
|
||||
stationName = _options.StationName,
|
||||
alarmType = request.AlarmType,
|
||||
alarmCode = request.AlarmCode,
|
||||
alarmMessage = request.AlarmMessage,
|
||||
barcode = request.Barcode,
|
||||
triggeredAt = request.TriggeredAt,
|
||||
machineName = Environment.MachineName
|
||||
})
|
||||
};
|
||||
var requestUri = BuildRequestUri(request);
|
||||
using var message = new HttpRequestMessage(new HttpMethod(_options.Method), requestUri);
|
||||
|
||||
foreach (var header in _options.Headers)
|
||||
{
|
||||
@@ -99,6 +87,24 @@ public sealed class AndonService : IAndonService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据运行时报警内容构建安灯请求地址。
|
||||
/// </summary>
|
||||
/// <param name="request">报警请求对象。</param>
|
||||
/// <returns>包含查询参数的请求地址。</returns>
|
||||
private Uri BuildRequestUri(AndonAlarmRequest request)
|
||||
{
|
||||
var builder = new UriBuilder(_options.Url);
|
||||
var query = HttpUtility.ParseQueryString(builder.Query);
|
||||
query["andonTheme"] = _options.AndonTheme;
|
||||
query["eid"] = _options.Eid;
|
||||
query["faultCode"] = request.AlarmMessage;
|
||||
query["fromSign"] = _options.FromSign;
|
||||
query["serviceId"] = _options.ServiceId;
|
||||
builder.Query = query.ToString() ?? string.Empty;
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送一次测试报警请求。
|
||||
/// </summary>
|
||||
|
||||
@@ -58,6 +58,27 @@ public sealed class SftpLookupService : ISftpLookupService
|
||||
return await Task.Run(() => CheckInternal(barcode, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试当前或指定配置下的 SFTP 连接可用性。
|
||||
/// </summary>
|
||||
/// <param name="options">待测试的 SFTP 配置;为 <see langword="null"/> 时使用当前应用配置。</param>
|
||||
/// <param name="cancellationToken">取消令牌。若在连接过程中取消,将终止测试。</param>
|
||||
/// <returns>连接测试结果。</returns>
|
||||
public async Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
|
||||
{
|
||||
SftpOptions effectiveOptions = options ?? _options;
|
||||
if (string.IsNullOrWhiteSpace(effectiveOptions.Host) || string.IsNullOrWhiteSpace(effectiveOptions.RootPath))
|
||||
{
|
||||
return new SftpConnectionTestOutcome
|
||||
{
|
||||
IsConfigurationError = true,
|
||||
StatusMessage = "SFTP 配置缺失 Host 或 RootPath。"
|
||||
};
|
||||
}
|
||||
|
||||
return await Task.Run(() => TestConnectionInternal(effectiveOptions, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在同步上下文中执行 SFTP 查询。
|
||||
/// </summary>
|
||||
@@ -70,9 +91,8 @@ public sealed class SftpLookupService : ISftpLookupService
|
||||
|
||||
try
|
||||
{
|
||||
using var client = CreateClient();
|
||||
client.ConnectionInfo.Timeout = TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs);
|
||||
client.Connect();
|
||||
using var client = CreateClient(_options);
|
||||
ConnectClient(client, _options);
|
||||
|
||||
if (!client.IsConnected)
|
||||
{
|
||||
@@ -163,18 +183,39 @@ public sealed class SftpLookupService : ISftpLookupService
|
||||
/// <returns>SFTP 客户端实例。</returns>
|
||||
private SftpClient CreateClient()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.PrivateKeyPath))
|
||||
{
|
||||
var privateKeyFile = string.IsNullOrWhiteSpace(_options.PrivateKeyPassphrase)
|
||||
? new PrivateKeyFile(_options.PrivateKeyPath)
|
||||
: new PrivateKeyFile(_options.PrivateKeyPath, _options.PrivateKeyPassphrase);
|
||||
return CreateClient(_options);
|
||||
}
|
||||
|
||||
var keyAuth = new PrivateKeyAuthenticationMethod(_options.Username, privateKeyFile);
|
||||
var connectionInfo = new ConnectionInfo(_options.Host, _options.Port, _options.Username, keyAuth);
|
||||
/// <summary>
|
||||
/// 根据指定配置创建 SFTP 客户端。
|
||||
/// </summary>
|
||||
/// <param name="options">SFTP 配置。</param>
|
||||
/// <returns>SFTP 客户端实例。</returns>
|
||||
private static SftpClient CreateClient(SftpOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.PrivateKeyPath))
|
||||
{
|
||||
var privateKeyFile = string.IsNullOrWhiteSpace(options.PrivateKeyPassphrase)
|
||||
? new PrivateKeyFile(options.PrivateKeyPath)
|
||||
: new PrivateKeyFile(options.PrivateKeyPath, options.PrivateKeyPassphrase);
|
||||
|
||||
var keyAuth = new PrivateKeyAuthenticationMethod(options.Username, privateKeyFile);
|
||||
var connectionInfo = new ConnectionInfo(options.Host, options.Port, options.Username, keyAuth);
|
||||
return new SftpClient(connectionInfo);
|
||||
}
|
||||
|
||||
return new SftpClient(_options.Host, _options.Port, _options.Username, _options.Password);
|
||||
return new SftpClient(options.Host, options.Port, options.Username, options.Password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一设置超时并建立 SFTP 连接。
|
||||
/// </summary>
|
||||
/// <param name="client">SFTP 客户端。</param>
|
||||
/// <param name="options">SFTP 配置。</param>
|
||||
private static void ConnectClient(SftpClient client, SftpOptions options)
|
||||
{
|
||||
client.ConnectionInfo.Timeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs);
|
||||
client.Connect();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -197,4 +238,80 @@ public sealed class SftpLookupService : ISftpLookupService
|
||||
{
|
||||
return path.Replace('\\', '/').TrimEnd('/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在同步上下文中执行 SFTP 连通性测试。
|
||||
/// </summary>
|
||||
/// <param name="options">待测试的配置。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>连接测试结果。</returns>
|
||||
private SftpConnectionTestOutcome TestConnectionInternal(SftpOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = CreateClient(options);
|
||||
ConnectClient(client, options);
|
||||
|
||||
if (!client.IsConnected)
|
||||
{
|
||||
return new SftpConnectionTestOutcome
|
||||
{
|
||||
IsSystemError = true,
|
||||
StatusMessage = "SFTP 未能建立连接。"
|
||||
};
|
||||
}
|
||||
|
||||
var rootPath = NormalizeDirectory(options.RootPath);
|
||||
if (!client.Exists(rootPath))
|
||||
{
|
||||
return new SftpConnectionTestOutcome
|
||||
{
|
||||
IsConfigurationError = true,
|
||||
ConnectionSucceeded = true,
|
||||
StatusMessage = $"SFTP 根目录不存在: {rootPath}"
|
||||
};
|
||||
}
|
||||
|
||||
return new SftpConnectionTestOutcome
|
||||
{
|
||||
IsSuccess = true,
|
||||
ConnectionSucceeded = true,
|
||||
RootPathAccessible = true,
|
||||
StatusMessage = "SFTP 连接成功,根目录可访问。"
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (SshAuthenticationException ex)
|
||||
{
|
||||
_logger.LogError(ex, "SFTP 认证失败");
|
||||
return new SftpConnectionTestOutcome
|
||||
{
|
||||
IsSystemError = true,
|
||||
StatusMessage = $"SFTP 认证失败: {ex.Message}"
|
||||
};
|
||||
}
|
||||
catch (SshConnectionException ex)
|
||||
{
|
||||
_logger.LogError(ex, "SFTP 连接失败");
|
||||
return new SftpConnectionTestOutcome
|
||||
{
|
||||
IsSystemError = true,
|
||||
StatusMessage = $"SFTP 连接失败: {ex.Message}"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SFTP 连接测试异常");
|
||||
return new SftpConnectionTestOutcome
|
||||
{
|
||||
IsSystemError = true,
|
||||
StatusMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
|
||||
await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||
PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
@@ -113,6 +114,50 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
await CancelActiveWorkflowAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在应用启动后执行一次 SFTP 探活,仅更新状态与日志,不阻断程序运行。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。若应用正在关闭,则中止本次探活。</param>
|
||||
/// <returns>表示探活完成的任务。</returns>
|
||||
public async Task ProbeSftpOnStartupAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
SftpConnectionTestOutcome outcome = await _sftpLookupService
|
||||
.TestConnectionAsync(null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.SftpStatus = outcome.IsSuccess
|
||||
? "在线"
|
||||
: outcome.ConnectionSucceeded || outcome.IsConfigurationError
|
||||
? "配置异常"
|
||||
: outcome.IsSystemError
|
||||
? "异常"
|
||||
: "未验证";
|
||||
});
|
||||
|
||||
if (outcome.IsSuccess)
|
||||
{
|
||||
_appLogger.LogInformation($"启动时 SFTP 探活成功: {outcome.StatusMessage}", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_appLogger.LogWarning($"启动时 SFTP 探活失败: {outcome.StatusMessage}", true);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_appLogger.LogInformation("启动时 SFTP 探活已取消。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_appLogger.LogError(ex, "启动时 SFTP 探活异常");
|
||||
UpdateSnapshot(snapshot => snapshot.SftpStatus = "异常");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动复位流程状态。
|
||||
/// </summary>
|
||||
@@ -123,15 +168,31 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false);
|
||||
ResetProcessStateCore();
|
||||
await WritePlcStateAsync(state =>
|
||||
try
|
||||
{
|
||||
ResetResultBits(state);
|
||||
state.FlowStateCode = WorkflowState.Idle.ToFlowStateCode();
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
PublishRuntimeState(WorkflowState.Idle, "已复位,等待 PCB 到位");
|
||||
_appLogger.LogInformation("已执行流程复位。", true);
|
||||
await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false);
|
||||
ResetProcessStateCore();
|
||||
await WritePlcStateAsync(state =>
|
||||
{
|
||||
ResetResultBits(state);
|
||||
state.FlowStateCode = WorkflowState.Idle.ToFlowStateCode();
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
PublishRuntimeState(WorkflowState.Idle, "已复位,等待 PCB 到位");
|
||||
_appLogger.LogInformation("已执行流程复位。", true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_appLogger.LogError(ex, "手动复位失败");
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.FaultMessage = $"手动复位失败: {ex.Message}";
|
||||
snapshot.ResultDescription = "手动复位失败";
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -149,9 +210,25 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _plcService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot => snapshot.PlcStatus = "已重连");
|
||||
_appLogger.LogInformation("PLC 已手动重连。", true);
|
||||
try
|
||||
{
|
||||
await _plcService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot => snapshot.PlcStatus = "已重连");
|
||||
_appLogger.LogInformation("PLC 已手动重连。", true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_appLogger.LogError(ex, "PLC 手动重连失败");
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.PlcStatus = $"重连失败: {ex.Message}";
|
||||
snapshot.FaultMessage = $"PLC 手动重连失败: {ex.Message}";
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -169,9 +246,25 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _scannerService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot => snapshot.ScannerStatus = "已重连");
|
||||
_appLogger.LogInformation("扫码枪已手动重连。", true);
|
||||
try
|
||||
{
|
||||
await _scannerService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot => snapshot.ScannerStatus = "已重连");
|
||||
_appLogger.LogInformation("扫码枪已手动重连。", true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_appLogger.LogError(ex, "扫码枪手动重连失败");
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.ScannerStatus = $"重连失败: {ex.Message}";
|
||||
snapshot.FaultMessage = $"扫码枪手动重连失败: {ex.Message}";
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -189,10 +282,26 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var result = await _andonService.TestAsync(cancellationToken).ConfigureAwait(false);
|
||||
var status = result.IsSuccess ? "测试成功" : $"测试失败: {result.ErrorMessage}";
|
||||
UpdateSnapshot(snapshot => snapshot.AndonStatus = status);
|
||||
_appLogger.LogInformation($"安灯接口手动测试结果: {status}", true);
|
||||
try
|
||||
{
|
||||
var result = await _andonService.TestAsync(cancellationToken).ConfigureAwait(false);
|
||||
var status = result.IsSuccess ? "测试成功" : $"测试失败: {result.ErrorMessage}";
|
||||
UpdateSnapshot(snapshot => snapshot.AndonStatus = status);
|
||||
_appLogger.LogInformation($"安灯接口手动测试结果: {status}", true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_appLogger.LogError(ex, "安灯接口手动测试失败");
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.AndonStatus = $"测试失败: {ex.Message}";
|
||||
snapshot.FaultMessage = $"安灯接口手动测试失败: {ex.Message}";
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -158,6 +158,14 @@ public interface ISftpLookupService
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>文件校验结果。</returns>
|
||||
Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 测试当前或指定配置下的 SFTP 连接可用性。
|
||||
/// </summary>
|
||||
/// <param name="options">待测试的 SFTP 配置;传入 <see langword="null"/> 时使用当前应用配置。</param>
|
||||
/// <param name="cancellationToken">取消令牌。若在连接过程中取消,将终止本次测试且不更新状态。</param>
|
||||
/// <returns>连接测试结果。</returns>
|
||||
Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -215,6 +223,20 @@ public interface IWorkflowControlService
|
||||
Task TestAndonAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定义管理员解锁密码弹窗交互能力。
|
||||
/// </summary>
|
||||
public interface IAdminUnlockDialogService
|
||||
{
|
||||
/// <summary>
|
||||
/// 显示管理员密码输入弹窗并校验是否通过。
|
||||
/// </summary>
|
||||
/// <param name="expectedPassword">期望密码。</param>
|
||||
/// <param name="sessionTimeout">本次解锁会话持续时间。</param>
|
||||
/// <returns>输入正确返回 <see langword="true"/>;取消或错误返回 <see langword="false"/>。</returns>
|
||||
bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定义运行态快照与 UI 事件分发能力。
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user