Jelajahi Sumber

feat: 借鉴 AutoFMA 第二批——连接管理/用户锁定/报警写入/在线升级

- 报警持久化改为 Channel 后台串行写入,退出时 flush
- 新增 ConnectionStatusService 集中管理 PLC/Hub 连接状态
- 用户登录锁定持久化到数据库,重启不丢失
- 新增 UpdateService 在线升级(委托注入模式)
- 表驱动状态机跳过(Stateless 已覆盖)
DESKTOP-74CLTRG\Leol 1 bulan lalu
induk
melakukan
ce12925260

+ 27 - 0
docs/AutoFMA借鉴实施计划.md

@@ -13,6 +13,33 @@
 | 3 | 配置服务热加载 | ConfigService | P1 | ✅ |
 | 4 | 审计日志 Channel 异步写入 | AuditService | P1 | ✅ |
 | 5 | 日志缓冲服务 | LogBufferService | P2 | ✅ |
+| 6 | 表驱动状态机 | SessionStateMachine | P1 | ⏭️ 跳过(Stateless 已覆盖) |
+| 7 | 连接状态集中管理 | ConnectionStatusService | P2 | ✅ |
+| 8 | 用户锁定持久化 | UserService | P3 | ✅ |
+| 9 | 报警历史 Channel 写入 | AlarmMonitorService | P3 | ✅ |
+| 10 | 在线升级委托注入 | UpdateService | P3 | ✅ |
+
+## 第二批实施说明
+
+### 6. 表驱动状态机 — 跳过
+YZWater3 已使用 Stateless 库实现 `DeviceStateMachine`(`Configure().Permit()` 即转移表)。
+AutoFMA 的 `SessionStateMachine` 是为"扫码→驱动→测量→MES"多阶段会话定制,YZWater3 无对应业务流程,不重复引入。
+
+### 7. 连接状态集中管理
+- `PlcService`/`HubServer`/`HubClient` 添加状态变更事件
+- 新增 `ConnectionStatusService` 单例:订阅各服务事件,统一存储,提供显示文本 + 状态变更通知
+
+### 8. 用户锁定持久化
+- `User` 模型新增 `FailedLoginAttempts`/`LockoutUntil` 字段
+- `AuthService` 锁定逻辑从内存字典改为数据库持久化(重启不丢失)
+
+### 9. 报警历史 Channel 写入
+- `AlarmService` 两处直接 `Insertable` 改为 `Channel<AlarmRecord>` 后台串行写入
+- 退出时 flush(复用 AuditService 模式)
+
+### 10. 在线升级委托注入
+- 新增 `UpdateService`(Core 层):版本管理 + `SetTriggerAction` 委托注入
+- UI 层实际更新逻辑后续接入(Avalonia 无 AutoUpdater.NET 官方支持,先搭架子)
 
 ---
 

+ 17 - 0
src/YZWater.Avalonia/App.axaml.cs

@@ -277,6 +277,9 @@ public partial class App : Application
         _hubServer.Start();
         _hubServer.CommandReceived += HandleHubCommandAsync;
 
+        // 注册到连接状态集中管理
+        ConnectionStatusService.Instance.RegisterHubServer(_hubServer);
+
         PlcPollingService.Instance.DataUpdated += OnDataUpdatedForBroadcast;
         AlarmService.Instance.AlarmRaised += OnAlarmRaisedForBroadcast;
 
@@ -316,6 +319,9 @@ public partial class App : Application
     {
         _hubClient = new HubClient { ServerUrl = config.HubServerUrl, Token = config.HubToken };
 
+        // 注册到连接状态集中管理
+        ConnectionStatusService.Instance.RegisterHubClient(_hubClient);
+
         // 客户端也启动本地数据日志,断连后仍有历史数据
         _dataLogger = new DataLoggingService(PlcPollingService.Instance);
         _dataLogger.Start();
@@ -508,6 +514,17 @@ public partial class App : Application
         }
         _hubClient = null;
 
+        // 取消 Hub 连接状态注册
+        try
+        {
+            ConnectionStatusService.Instance.UnregisterHubs();
+            Serilog.Log.Information("[{Elapsed}ms] ConnectionStatusService Hub 注册已取消", sw.ElapsedMilliseconds);
+        }
+        catch (Exception ex)
+        {
+            Serilog.Log.Warning(ex, "[{Elapsed}ms] ConnectionStatusService 取消注册失败", sw.ElapsedMilliseconds);
+        }
+
         try
         {
             _hubServer?.Dispose();

+ 8 - 0
src/YZWater.Core/Models/SystemConfig.cs

@@ -1,4 +1,5 @@
 using CommunityToolkit.Mvvm.ComponentModel;
+using YZWater.Core.Services;
 
 namespace YZWater.Core.Models;
 
@@ -172,6 +173,13 @@ public partial class SystemConfig : ObservableObject
     /// 备份配置
     /// </summary>
     public BackupConfig Backup { get; set; } = new();
+
+    // ═══ 在线升级配置 ═══
+
+    /// <summary>
+    /// 在线升级配置
+    /// </summary>
+    public UpdateService.UpdateConfig Update { get; set; } = new();
 }
 
 /// <summary>

+ 11 - 0
src/YZWater.Core/Models/User.cs

@@ -72,4 +72,15 @@ public class User
     /// </summary>
     [SugarColumn(IsNullable = true)]
     public DateTime? LastLoginTime { get; set; }
+
+    /// <summary>
+    /// 连续登录失败次数(持久化,重启不丢失)
+    /// </summary>
+    public int FailedLoginAttempts { get; set; }
+
+    /// <summary>
+    /// 账户锁定截止时间(超过此时间自动解锁)
+    /// </summary>
+    [SugarColumn(IsNullable = true)]
+    public DateTime? LockoutUntil { get; set; }
 }

+ 62 - 19
src/YZWater.Core/Services/AlarmService.cs

@@ -1,3 +1,4 @@
+using System.Threading.Channels;
 using CommunityToolkit.Mvvm.ComponentModel;
 using Serilog;
 using YZWater.Core.Models;
@@ -6,6 +7,7 @@ namespace YZWater.Core.Services;
 
 /// <summary>
 /// 报警服务 - 报警检测、持久化、确认、延迟
+/// 持久化通过 Channel 异步队列后台串行写入(借鉴 AutoFMA,防报警密集时阻塞轮询线程)
 /// </summary>
 public partial class AlarmService : ObservableObject, IDisposable
 {
@@ -30,6 +32,17 @@ public partial class AlarmService : ObservableObject, IDisposable
     private CancellationTokenSource? _cts;
     private bool _disposed;
 
+    /// <summary>报警写入通道(有界队列,防报警密集时线程池打满)</summary>
+    private readonly Channel<AlarmRecord> _writeChannel =
+        Channel.CreateBounded<AlarmRecord>(new BoundedChannelOptions(200)
+        {
+            FullMode = BoundedChannelFullMode.DropOldest
+        });
+
+    /// <summary>后台写入任务</summary>
+    private Task _writerTask = Task.CompletedTask;
+    private CancellationTokenSource _writerCts = new();
+
     // ─── 报警配置 ───
     public float LevelHighAlarm { get; set; } = 80f;
     public float LevelLowAlarm { get; set; } = 20f;
@@ -69,6 +82,9 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         _pollingService = pollingService;
         _pollingService.DataUpdated += OnDataUpdated;
+
+        // 启动报警后台写入任务
+        _writerTask = Task.Run(() => ProcessWriteQueueAsync(_writerCts.Token));
     }
 
     /// <summary>
@@ -387,22 +403,45 @@ public partial class AlarmService : ObservableObject, IDisposable
     }
 
     /// <summary>
-    /// 保存报警到数据库
+    /// 后台消费 Channel 中的报警记录,串行写入数据库
     /// </summary>
-    private async Task SaveAlarmAsync(AlarmRecord alarm)
+    private async Task ProcessWriteQueueAsync(CancellationToken ct)
     {
         try
         {
-            var id = await DatabaseService.Db.Insertable(alarm).ExecuteReturnIdentityAsync();
-            alarm.Id = id;
-            Log.Debug("报警记录已保存: {Id}", alarm.Id);
+            await foreach (var alarm in _writeChannel.Reader.ReadAllAsync(ct))
+            {
+                try
+                {
+                    var id = await DatabaseService.Db.Insertable(alarm).ExecuteReturnIdentityAsync();
+                    alarm.Id = id;
+                    Log.Debug("报警记录已保存: {Id}", alarm.Id);
+                }
+                catch (Exception ex)
+                {
+                    Log.Error(ex, "保存报警记录失败");
+                }
+            }
+        }
+        catch (OperationCanceledException)
+        {
+            // 正常退出
         }
         catch (Exception ex)
         {
-            Log.Error(ex, "保存报警记录失败");
+            Log.Error(ex, "报警后台写入异常");
         }
     }
 
+    /// <summary>
+    /// 保存报警到数据库(通过 Channel 异步写入,不阻塞轮询线程)
+    /// </summary>
+    private Task SaveAlarmAsync(AlarmRecord alarm)
+    {
+        _writeChannel.Writer.TryWrite(alarm);
+        return Task.CompletedTask;
+    }
+
     /// <summary>
     /// 确认报警
     /// </summary>
@@ -506,21 +545,14 @@ public partial class AlarmService : ObservableObject, IDisposable
     }
 
     /// <summary>
-    /// 处理远程报警(客户端模式,持久化到本地数据库)
+    /// 处理远程报警(客户端模式,持久化到本地数据库,通过 Channel 异步写入)
     /// </summary>
-    public async Task ProcessRemoteAlarmAsync(AlarmRecord alarm)
+    public Task ProcessRemoteAlarmAsync(AlarmRecord alarm)
     {
-        try
-        {
-            var id = await DatabaseService.Db.Insertable(alarm).ExecuteReturnIdentityAsync();
-            alarm.Id = id;
-            AlarmRaised?.Invoke(alarm);
-            UpdateAlarmState();
-        }
-        catch (Exception ex)
-        {
-            Log.Error(ex, "处理远程报警失败");
-        }
+        _writeChannel.Writer.TryWrite(alarm);
+        AlarmRaised?.Invoke(alarm);
+        UpdateAlarmState();
+        return Task.CompletedTask;
     }
 
     public void Dispose()
@@ -529,6 +561,17 @@ public partial class AlarmService : ObservableObject, IDisposable
         _disposed = true;
         _pollingService.DataUpdated -= OnDataUpdated;
         Stop();
+
+        // 关闭 Channel 并等待后台写入完成(flush,最多等 5 秒)
+        try
+        {
+            _writeChannel.Writer.TryComplete();
+            _writerTask.Wait(TimeSpan.FromSeconds(5));
+        }
+        catch { /* 超时或异常不影响退出 */ }
+        _writerCts.Cancel();
+        _writerCts.Dispose();
+
         Log.Information("报警服务已释放");
     }
 }

+ 47 - 21
src/YZWater.Core/Services/AuthService.cs

@@ -26,11 +26,6 @@ public static class AuthService
     private static User? _currentUser;
     private static DateTime _lastActivityTime = DateTime.Now;
 
-    /// <summary>
-    /// 登录失败记录:用户名 → (失败次数, 最后一次失败时间)
-    /// </summary>
-    private static readonly Dictionary<string, (int Count, DateTime Time)> _loginAttempts = new();
-
     /// <summary>
     /// 当前登录用户
     /// </summary>
@@ -340,18 +335,22 @@ public static class AuthService
         }
     }
 
-    // ─── 登录失败锁定 ───
+    // ─── 登录失败锁定(持久化到数据库,重启不丢失) ───
 
     private static bool IsAccountLocked(string userName)
     {
-        if (!_loginAttempts.TryGetValue(userName, out var attempt))
-            return false;
-        if (attempt.Count < MaxFailedAttempts)
-            return false;
-        // 锁定期已过,自动解锁
-        if ((DateTime.Now - attempt.Time).TotalMinutes >= LockoutDurationMinutes)
+        var user = DatabaseService.Db.Queryable<User>()
+            .Where(u => u.UserName == userName)
+            .First();
+        if (user?.LockoutUntil == null) return false;
+        // 锁定期已过,自动解锁(清零失败计数)
+        if (user.LockoutUntil.Value <= DateTime.Now)
         {
-            _loginAttempts.Remove(userName);
+            DatabaseService.Db.Updateable<User>()
+                .SetColumns(u => u.FailedLoginAttempts, 0)
+                .SetColumns(u => u.LockoutUntil, null)
+                .Where(u => u.Id == user.Id)
+                .ExecuteCommand();
             return false;
         }
         return true;
@@ -359,23 +358,50 @@ public static class AuthService
 
     private static int GetLockoutRemainingMinutes(string userName)
     {
-        if (!_loginAttempts.TryGetValue(userName, out var attempt))
-            return 0;
-        var elapsed = (DateTime.Now - attempt.Time).TotalMinutes;
-        return Math.Max(0, (int)Math.Ceiling(LockoutDurationMinutes - elapsed));
+        var user = DatabaseService.Db.Queryable<User>()
+            .Where(u => u.UserName == userName)
+            .First();
+        if (user?.LockoutUntil == null) return 0;
+        var remaining = (user.LockoutUntil.Value - DateTime.Now).TotalMinutes;
+        return Math.Max(0, (int)Math.Ceiling(remaining));
     }
 
     private static void RecordFailedAttempt(string userName)
     {
-        if (_loginAttempts.TryGetValue(userName, out var attempt))
-            _loginAttempts[userName] = (attempt.Count + 1, DateTime.Now);
+        var user = DatabaseService.Db.Queryable<User>()
+            .Where(u => u.UserName == userName)
+            .First();
+        if (user == null) return;
+
+        var attempts = user.FailedLoginAttempts + 1;
+        if (attempts >= MaxFailedAttempts)
+        {
+            DatabaseService.Db.Updateable<User>()
+                .SetColumns(u => u.FailedLoginAttempts, attempts)
+                .SetColumns(u => u.LockoutUntil, DateTime.Now.AddMinutes(LockoutDurationMinutes))
+                .Where(u => u.Id == user.Id)
+                .ExecuteCommand();
+            Log.Warning("用户 {UserName} 登录失败 {Attempts} 次,账户已锁定 {Minutes} 分钟",
+                userName, attempts, LockoutDurationMinutes);
+        }
         else
-            _loginAttempts[userName] = (1, DateTime.Now);
+        {
+            DatabaseService.Db.Updateable<User>()
+                .SetColumns(u => u.FailedLoginAttempts, attempts)
+                .Where(u => u.Id == user.Id)
+                .ExecuteCommand();
+            Log.Information("用户 {UserName} 登录失败 {Attempts}/{Max} 次",
+                userName, attempts, MaxFailedAttempts);
+        }
     }
 
     private static void ClearFailedAttempts(string userName)
     {
-        _loginAttempts.Remove(userName);
+        DatabaseService.Db.Updateable<User>()
+            .SetColumns(u => u.FailedLoginAttempts, 0)
+            .SetColumns(u => u.LockoutUntil, null)
+            .Where(u => u.UserName == userName)
+            .ExecuteCommand();
     }
 
     /// <summary>

+ 192 - 0
src/YZWater.Core/Services/ConnectionStatusService.cs

@@ -0,0 +1,192 @@
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 连接状态集中管理服务(借鉴 AutoFMA ConnectionStatusService)
+///
+/// 设计原则:
+///   1. 统一订阅各设备服务的事件(PlcService / PlcPollingService / HubServer / HubClient)
+///   2. 各 ViewModel 通过此服务获取连接状态,避免多处重复订阅
+///   3. 状态实际变化时才触发 StatusChanged
+///   4. 提供 DisplayText 供 UI 直接绑定
+///
+/// HubServer/HubClient 是 App 层按运行模式动态创建的,
+/// 通过 RegisterHubServer / RegisterHubClient / UnregisterHubs 动态挂接。
+/// </summary>
+public sealed class ConnectionStatusService : IDisposable
+{
+    private static ConnectionStatusService? _instance;
+    private static readonly object _instanceLock = new();
+    public static ConnectionStatusService Instance
+    {
+        get { lock (_instanceLock) { return _instance ??= new ConnectionStatusService(); } }
+    }
+
+    private readonly object _lock = new();
+
+    // ─── 状态 ───
+    private bool _isPlcConnected;
+    private bool _isHubServerRunning;
+    private bool _isHubClientConnected;
+
+    private HubServer? _hubServer;
+    private HubClient? _hubClient;
+
+    // ─── 属性 ───
+
+    /// <summary>PLC 是否已连接(含轮询通信状态)</summary>
+    public bool IsPlcConnected { get { lock (_lock) return _isPlcConnected; } }
+
+    /// <summary>中控服务端是否运行中</summary>
+    public bool IsHubServerRunning { get { lock (_lock) return _isHubServerRunning; } }
+
+    /// <summary>中控客户端是否已连接</summary>
+    public bool IsHubClientConnected { get { lock (_lock) return _isHubClientConnected; } }
+
+    // ─── 显示文本(供 UI 直接绑定) ───
+
+    public string PlcDisplayText
+    {
+        get
+        {
+            var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
+            if (mode == "Mock") return "PLC: 模拟模式";
+            return IsPlcConnected ? "PLC: 已连接" : "PLC: 未连接";
+        }
+    }
+
+    public string HubDisplayText
+    {
+        get
+        {
+            var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
+            return mode switch
+            {
+                "Server" => IsHubServerRunning ? "中控: 服务端运行中" : "中控: 服务端已停止",
+                "Client" => IsHubClientConnected ? "中控: 已连接服务端" : "中控: 未连接",
+                _ => "中控: 直连模式"
+            };
+        }
+    }
+
+    // ─── 事件 ───
+
+    /// <summary>任一连接状态变化时触发</summary>
+    public event Action? StatusChanged;
+
+    private ConnectionStatusService()
+    {
+        // PLC 底层连接状态
+        PlcService.ConnectionChanged += OnPlcConnectionChanged;
+
+        // PLC 轮询通信状态(轮询失败时 IsPlcConnected 会变为 false,反映真实通信健康度)
+        PlcPollingService.Instance.DataUpdated += OnPlcDataUpdated;
+    }
+
+    // ─── Hub 动态挂接(App 层按运行模式调用) ───
+
+    /// <summary>
+    /// 注册中控服务端(Server 模式启动后调用)
+    /// </summary>
+    public void RegisterHubServer(HubServer server)
+    {
+        UnregisterHubs();
+        lock (_lock)
+        {
+            _hubServer = server;
+            _isHubServerRunning = server.IsRunning;
+        }
+        server.RunningChanged += OnHubServerRunningChanged;
+    }
+
+    /// <summary>
+    /// 注册中控客户端(Client 模式创建后调用)
+    /// </summary>
+    public void RegisterHubClient(HubClient client)
+    {
+        UnregisterHubs();
+        lock (_lock)
+        {
+            _hubClient = client;
+            _isHubClientConnected = client.IsConnected;
+        }
+        client.ConnectionChanged += OnHubClientConnectionChanged;
+    }
+
+    /// <summary>
+    /// 取消注册 Hub(停止服务/登出时调用)
+    /// </summary>
+    public void UnregisterHubs()
+    {
+        lock (_lock)
+        {
+            if (_hubServer != null) _hubServer.RunningChanged -= OnHubServerRunningChanged;
+            if (_hubClient != null) _hubClient.ConnectionChanged -= OnHubClientConnectionChanged;
+            var changed = _isHubServerRunning || _isHubClientConnected;
+            _hubServer = null;
+            _hubClient = null;
+            _isHubServerRunning = false;
+            _isHubClientConnected = false;
+            if (changed) RaiseStatusChanged();
+        }
+    }
+
+    // ─── 事件处理器 ───
+
+    private void OnPlcConnectionChanged(bool connected)
+    {
+        bool changed;
+        lock (_lock)
+        {
+            changed = _isPlcConnected != connected;
+            _isPlcConnected = connected;
+        }
+        if (changed) RaiseStatusChanged();
+    }
+
+    private void OnPlcDataUpdated(PlcDataModel data)
+    {
+        bool changed;
+        lock (_lock)
+        {
+            changed = _isPlcConnected != data.IsPlcConnected;
+            _isPlcConnected = data.IsPlcConnected;
+        }
+        if (changed) RaiseStatusChanged();
+    }
+
+    private void OnHubServerRunningChanged(bool running)
+    {
+        bool changed;
+        lock (_lock)
+        {
+            changed = _isHubServerRunning != running;
+            _isHubServerRunning = running;
+        }
+        if (changed) RaiseStatusChanged();
+    }
+
+    private void OnHubClientConnectionChanged(bool connected)
+    {
+        bool changed;
+        lock (_lock)
+        {
+            changed = _isHubClientConnected != connected;
+            _isHubClientConnected = connected;
+        }
+        if (changed) RaiseStatusChanged();
+    }
+
+    private void RaiseStatusChanged()
+    {
+        StatusChanged?.Invoke();
+    }
+
+    public void Dispose()
+    {
+        PlcService.ConnectionChanged -= OnPlcConnectionChanged;
+        PlcPollingService.Instance.DataUpdated -= OnPlcDataUpdated;
+        UnregisterHubs();
+    }
+}

+ 7 - 0
src/YZWater.Core/Services/HubServer.cs

@@ -76,6 +76,11 @@ public class HubServer : IDisposable
     /// </summary>
     public event Action<int>? ClientCountChanged;
 
+    /// <summary>
+    /// 服务端运行状态变更事件(参数为当前是否运行)
+    /// </summary>
+    public event Action<bool>? RunningChanged;
+
     /// <summary>
     /// 收到命令事件(由外部处理实际执行)
     /// </summary>
@@ -140,6 +145,7 @@ public class HubServer : IDisposable
 
         var protocol = UseTls ? "wss" : "ws";
         Log.Information("中控服务端已启动,端口: {Port},协议: {Protocol}", Port, protocol);
+        RunningChanged?.Invoke(true);
     }
 
     /// <summary>
@@ -185,6 +191,7 @@ public class HubServer : IDisposable
 
         IsRunning = false;
         Log.Information("HubServer 已停止");
+        RunningChanged?.Invoke(false);
     }
 
     /// <summary>

+ 18 - 2
src/YZWater.Core/Services/PlcService.cs

@@ -28,6 +28,21 @@ public static class PlcService
     /// </summary>
     public static bool IsConnected => _isConnected;
 
+    /// <summary>
+    /// PLC 连接状态变更事件(参数为当前是否连接)
+    /// </summary>
+    public static event Action<bool>? ConnectionChanged;
+
+    /// <summary>
+    /// 更新连接状态(状态实际变化时才触发事件)
+    /// </summary>
+    private static void SetConnectionState(bool connected)
+    {
+        if (_isConnected == connected) return;
+        _isConnected = connected;
+        ConnectionChanged?.Invoke(connected);
+    }
+
     /// <summary>
     /// PLC 实例(向后兼容)
     /// </summary>
@@ -107,6 +122,7 @@ public static class PlcService
                 result = await _plc!.ConnectServerAsync();
             }
             _isConnected = result.IsSuccess;
+            SetConnectionState(_isConnected);
             if (_isConnected)
             {
                 Log.Information("PLC 连接成功");
@@ -120,7 +136,7 @@ public static class PlcService
         catch (Exception ex)
         {
             Log.Error(ex, "PLC 连接异常");
-            _isConnected = false;
+            SetConnectionState(false);
             return false;
         }
     }
@@ -132,7 +148,7 @@ public static class PlcService
     {
         _driver?.Disconnect();
         _plc?.ConnectClose();
-        _isConnected = false;
+        SetConnectionState(false);
         Log.Information("PLC 已断开连接");
     }
 

+ 145 - 0
src/YZWater.Core/Services/UpdateService.cs

@@ -0,0 +1,145 @@
+using System.Reflection;
+using Serilog;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 在线升级服务(借鉴 AutoFMA UpdateService,委托注入模式)
+///
+/// 设计原则:
+///   1. Core 层只负责版本管理和更新触发,不依赖具体 UI 框架
+///   2. 实际的更新检查/下载/安装逻辑由 UI 层通过 SetTriggerAction 注入
+///   3. config.json 配置 Update.Enabled / CheckOnStartup / CheckIntervalHours
+///
+/// Avalonia 无 AutoUpdater.NET 官方支持,UI 层可接入 Squirrel 或自研下载器后注入。
+/// </summary>
+public static class UpdateService
+{
+    private static DateTime? _lastCheckTime;
+    private static Action? _triggerAction;
+    private static System.Threading.Timer? _checkTimer;
+
+    /// <summary>
+    /// 更新配置
+    /// </summary>
+    public class UpdateConfig
+    {
+        /// <summary>是否启用在线更新</summary>
+        public bool Enabled { get; set; }
+
+        /// <summary>更新清单地址(appcast URL)</summary>
+        public string AppcastUrl { get; set; } = string.Empty;
+
+        /// <summary>启动时是否检查更新</summary>
+        public bool CheckOnStartup { get; set; } = true;
+
+        /// <summary>检查间隔(小时,默认 24)</summary>
+        public int CheckIntervalHours { get; set; } = 24;
+    }
+
+    /// <summary>
+    /// 当前程序版本(Major.Minor.Build)
+    /// </summary>
+    public static string GetCurrentVersion()
+    {
+        var version = Assembly.GetEntryAssembly()?.GetName().Version;
+        return version != null ? $"{version.Major}.{version.Minor}.{version.Build}" : "0.0.0";
+    }
+
+    /// <summary>上次检查时间</summary>
+    public static DateTime? LastCheckTime => _lastCheckTime;
+
+    /// <summary>更新触发器是否已注入</summary>
+    public static bool IsTriggerRegistered => _triggerAction != null;
+
+    /// <summary>
+    /// 注入实际更新触发逻辑(由 UI 层调用,如 Squirrel 下载安装流程)
+    /// </summary>
+    public static void SetTriggerAction(Action triggerAction)
+    {
+        _triggerAction = triggerAction;
+        Log.Information("在线更新触发器已注入");
+    }
+
+    /// <summary>
+    /// 触发在线更新
+    /// </summary>
+    public static bool TriggerUpdate()
+    {
+        if (_triggerAction == null)
+        {
+            Log.Warning("更新触发器未注入,无法执行在线更新");
+            return false;
+        }
+
+        Log.Information("触发在线更新,当前版本: {Version}", GetCurrentVersion());
+        _lastCheckTime = DateTime.Now;
+        try
+        {
+            _triggerAction();
+            return true;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "触发在线更新失败");
+            return false;
+        }
+    }
+
+    /// <summary>
+    /// 启动定期检查(启动时检查一次 + 按 CheckIntervalHours 定时)
+    /// </summary>
+    public static void StartPeriodicCheck(Action onCheckDue)
+    {
+        var config = ConfigService.GetConfig()?.Update;
+        if (config is not { Enabled: true })
+        {
+            Log.Debug("在线更新未启用,跳过定期检查");
+            return;
+        }
+
+        var interval = TimeSpan.FromHours(Math.Max(1, config.CheckIntervalHours));
+
+        // 启动时检查一次
+        if (config.CheckOnStartup)
+        {
+            Task.Run(() =>
+            {
+                try
+                {
+                    _lastCheckTime = DateTime.Now;
+                    onCheckDue();
+                }
+                catch (Exception ex)
+                {
+                    Log.Error(ex, "启动时检查更新失败");
+                }
+            });
+        }
+
+        // 定时检查
+        _checkTimer ??= new System.Threading.Timer(_ =>
+        {
+            try
+            {
+                _lastCheckTime = DateTime.Now;
+                onCheckDue();
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "定时检查更新失败");
+            }
+        }, null, interval, interval);
+
+        Log.Information("在线更新定期检查已启动,间隔 {Hours} 小时", config.CheckIntervalHours);
+    }
+
+    /// <summary>
+    /// 停止定期检查
+    /// </summary>
+    public static void StopPeriodicCheck()
+    {
+        _checkTimer?.Dispose();
+        _checkTimer = null;
+    }
+}