diff --git a/Ayay.Solution.sln b/Ayay.Solution.sln index e783899..0e9efe0 100644 --- a/Ayay.Solution.sln +++ b/Ayay.Solution.sln @@ -1,48 +1,24 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36623.8 d17.14 +VisualStudioVersion = 17.14.36623.8 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraSdk", "SHH.CameraSdk\SHH.CameraSdk.csproj", "{21B70A94-43FC-4D17-AB83-9E4B5178397E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraService", "SHH.CameraService\SHH.CameraService.csproj", "{033B348B-4588-4C81-8D6C-D953E8E7967B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.Contracts", "SHH.Contracts\SHH.Contracts.csproj", "{E7A63644-7A55-4267-99D2-7D0A7D54B43C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraDashboard", "SHH.CameraDashboard\SHH.CameraDashboard.csproj", "{03C249D7-BCF1-404D-AD09-7AB39BA263AD}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.ProcessLaunchers", "SHH.ProcessLaunchers\SHH.ProcessLaunchers.csproj", "{E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.Contracts.Grpc", "SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj", "{5CBDD688-1CD0-4E63-81C5-8E18750D891A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ayay.SerilogLogs", "Ayay.SerilogLogs\Ayay.SerilogLogs.csproj", "{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraSdk", "SHH.CameraSdk\SHH.CameraSdk.csproj", "{7B906CA8-28B6-B1E3-CA10-54749D96294B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraService", "SHH.CameraService\SHH.CameraService.csproj", "{FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.ProcessLaunchers", "SHH.ProcessLaunchers\SHH.ProcessLaunchers.csproj", "{20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {21B70A94-43FC-4D17-AB83-9E4B5178397E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {21B70A94-43FC-4D17-AB83-9E4B5178397E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {21B70A94-43FC-4D17-AB83-9E4B5178397E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {21B70A94-43FC-4D17-AB83-9E4B5178397E}.Release|Any CPU.Build.0 = Release|Any CPU - {033B348B-4588-4C81-8D6C-D953E8E7967B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {033B348B-4588-4C81-8D6C-D953E8E7967B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {033B348B-4588-4C81-8D6C-D953E8E7967B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {033B348B-4588-4C81-8D6C-D953E8E7967B}.Release|Any CPU.Build.0 = Release|Any CPU - {E7A63644-7A55-4267-99D2-7D0A7D54B43C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E7A63644-7A55-4267-99D2-7D0A7D54B43C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E7A63644-7A55-4267-99D2-7D0A7D54B43C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E7A63644-7A55-4267-99D2-7D0A7D54B43C}.Release|Any CPU.Build.0 = Release|Any CPU - {03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Release|Any CPU.Build.0 = Release|Any CPU - {E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}.Release|Any CPU.Build.0 = Release|Any CPU {5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Debug|Any CPU.Build.0 = Debug|Any CPU {5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -51,6 +27,18 @@ Global {0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Debug|Any CPU.Build.0 = Debug|Any CPU {0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Release|Any CPU.ActiveCfg = Release|Any CPU {0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Release|Any CPU.Build.0 = Release|Any CPU + {7B906CA8-28B6-B1E3-CA10-54749D96294B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B906CA8-28B6-B1E3-CA10-54749D96294B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B906CA8-28B6-B1E3-CA10-54749D96294B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B906CA8-28B6-B1E3-CA10-54749D96294B}.Release|Any CPU.Build.0 = Release|Any CPU + {FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}.Release|Any CPU.Build.0 = Release|Any CPU + {20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SHH.CameraSdk/Configs/ServiceConfig.cs b/SHH.CameraSdk/Configs/ServiceConfig.cs index f7de9ca..abd2fab 100644 --- a/SHH.CameraSdk/Configs/ServiceConfig.cs +++ b/SHH.CameraSdk/Configs/ServiceConfig.cs @@ -163,10 +163,9 @@ public class ServiceConfig string ip = parts[0].Trim(); string portStr = parts[1].Trim(); - string type = parts[2].Trim().ToLower(); // ★★★ 提取第四个字段作为备注 ★★★ - string desc = parts.Length >= 4 ? parts[3].Trim() : "未命名终端"; + string desc = parts.Length >= 4 ? parts[2].Trim() : "未命名终端"; if (int.TryParse(portStr, out int port)) { @@ -180,16 +179,10 @@ public class ServiceConfig }; // 添加前检查 Uri 是否重复 (备注不参与排重) - if (type == "video") - { - if (!config.VideoEndpoints.Any(e => e.Uri == zmqUri)) - config.VideoEndpoints.Add(endpoint); - } - else if (type == "command" || type == "text") - { - if (!config.CommandEndpoints.Any(e => e.Uri == zmqUri)) - config.CommandEndpoints.Add(endpoint); - } + if (!config.VideoEndpoints.Any(e => e.Uri == zmqUri)) + config.VideoEndpoints.Add(endpoint); + if (!config.CommandEndpoints.Any(e => e.Uri == zmqUri)) + config.CommandEndpoints.Add(endpoint); } } } diff --git a/SHH.CameraSdk/Core/Manager/CameraManager.cs b/SHH.CameraSdk/Core/Manager/CameraManager.cs index c0dc2ac..0323c9a 100644 --- a/SHH.CameraSdk/Core/Manager/CameraManager.cs +++ b/SHH.CameraSdk/Core/Manager/CameraManager.cs @@ -409,15 +409,15 @@ public class CameraManager : IDisposable, IAsyncDisposable /// 参数2: IsOnline (true=在线, false=离线) /// 参数3: Reason (变更原因) /// - public event Action? OnDeviceStatusChanged; + public event Action? OnDeviceStatusChanged; /// /// [内部方法] 供 Sentinel 调用,触发事件冒泡 /// - internal void NotifyStatusChange(long deviceId, bool isOnline, string reason) + internal void NotifyStatusChange(long deviceId, string ipAddress, bool isOnline, string reason) { // 仅仅是触发 C# 事件,完全不知道网络发送的存在 - OnDeviceStatusChanged?.Invoke(deviceId, isOnline, reason); + OnDeviceStatusChanged?.Invoke(deviceId, ipAddress, isOnline, reason); } #endregion diff --git a/SHH.CameraSdk/Core/Services/ConnectivitySentinel.cs b/SHH.CameraSdk/Core/Services/ConnectivitySentinel.cs index 7ecd4a1..5ef41e7 100644 --- a/SHH.CameraSdk/Core/Services/ConnectivitySentinel.cs +++ b/SHH.CameraSdk/Core/Services/ConnectivitySentinel.cs @@ -152,7 +152,7 @@ public class ConnectivitySentinel : $"持续断连超过{OFFLINE_DURATION_THRESHOLD}秒"; // ★★★ 核心动作:通知 Manager ★★★ - _manager.NotifyStatusChange(device.Id, isLogicallyOnline, reason); + _manager.NotifyStatusChange(device.Id, device.IpAddress, isLogicallyOnline, reason); } } diff --git a/SHH.CameraSdk/Drivers/DaHua/DahuaPlaySDK.cs b/SHH.CameraSdk/Drivers/DaHua/DahuaPlaySDK.cs index e6080c3..d2d483f 100644 --- a/SHH.CameraSdk/Drivers/DaHua/DahuaPlaySDK.cs +++ b/SHH.CameraSdk/Drivers/DaHua/DahuaPlaySDK.cs @@ -49,4 +49,25 @@ public static class DahuaPlaySDK [DllImport(DLL_PATH)] public static extern bool PLAY_SetStreamOpenMode(int nPort, uint nMode); + + // 解码模式枚举 + public enum DecodeType + { + DECODE_SW = 1, // 软解 (CPU) + DECODE_HW = 2, // 硬解拷贝模式 (GPU解码后拷贝回内存) + DECODE_HW_FAST = 3, // 硬解直接显示模式 (GPU解码直接渲染,最高性能) + DECODE_HW_NV_CUDA = 7, // 英伟达显卡 CUDA 硬解 (Ayay 推荐,多路并发最强) + DECODE_HW_D3D11 = 8 // D3D11 硬解 + } + + // 渲染模式枚举 + public enum RenderType + { + RENDER_GDI = 1, + RENDER_D3D9 = 4, + RENDER_D3D11 = 7 + } + + [DllImport(DLL_PATH, EntryPoint = "PLAY_SetEngine")] + public static extern bool PLAY_SetEngine(int nPort, DecodeType decodeType, RenderType renderType); } \ No newline at end of file diff --git a/SHH.CameraSdk/Drivers/DaHua/DahuaVideoSource.cs b/SHH.CameraSdk/Drivers/DaHua/DahuaVideoSource.cs index f902e68..0d1b4d3 100644 --- a/SHH.CameraSdk/Drivers/DaHua/DahuaVideoSource.cs +++ b/SHH.CameraSdk/Drivers/DaHua/DahuaVideoSource.cs @@ -3,6 +3,7 @@ using OpenCvSharp; using Serilog; using System.Runtime.ExceptionServices; using System.Security; +using static SHH.CameraSdk.DahuaPlaySDK; namespace SHH.CameraSdk; @@ -173,8 +174,35 @@ public class DahuaVideoSource : BaseVideoSource { _playPort = port; DahuaPlaySDK.PLAY_SetStreamOpenMode(_playPort, 0); + + // 打开流 DahuaPlaySDK.PLAY_OpenStream(_playPort, IntPtr.Zero, 0, 1024 * 1024 * 2); + // ================================================================================= + // 🚀 [新增代码] 性能优化:尝试开启大华 GPU 硬解码 + // 位置:必须在 PLAY_OpenStream 之后,PLAY_Play 之前 + // ================================================================================= + try + { + // nDecodeEngine: 1 = 开启硬解码 (Nvidia/Intel) + // 注意:大华 SDK 若不支持会自动降级,try-catch 仅为了防止 P/Invoke 签名缺失崩溃 + // Optimized: 使用新版接口开启硬件解码,优先尝试 CUDA 以保证 Ayay 的多路并发性能 + // nPort 是通过 PLAY_GetFreePort 获取的播放通道号 + bool success = PLAY_SetEngine(_playPort, DecodeType.DECODE_HW_NV_CUDA, RenderType.RENDER_D3D11); + + if (!success) + { + // 如果显卡不支持 CUDA,降级为普通硬解或软解 + PLAY_SetEngine(_playPort, DecodeType.DECODE_HW, RenderType.RENDER_D3D9); + } + _sdkLog.Information($"[Perf] Dahua 尝试开启硬解码. ID:{_config.Id} Port:{_playPort}"); + } + catch (Exception ex) + { + _sdkLog.Warning($"[Perf] Dahua 开启硬解码失败: {ex.Message}"); + } + + // 设置回调与播放 _decCallBack = new DahuaPlaySDK.DECCBFUN(SafeOnDecodingCallBack); DahuaPlaySDK.PLAY_SetDecCallBack(_playPort, _decCallBack); DahuaPlaySDK.PLAY_Play(_playPort, IntPtr.Zero); diff --git a/SHH.CameraSdk/Drivers/HikVision/HikPlayMethods.cs b/SHH.CameraSdk/Drivers/HikVision/HikPlayMethods.cs index 3cde241..e29966f 100644 --- a/SHH.CameraSdk/Drivers/HikVision/HikPlayMethods.cs +++ b/SHH.CameraSdk/Drivers/HikVision/HikPlayMethods.cs @@ -1,6 +1,4 @@ -using System; -using System.Runtime.InteropServices; -namespace SHH.CameraSdk; +namespace SHH.CameraSdk; /// /// 海康播放库 PlayCtrl.dll 的封装 @@ -343,5 +341,11 @@ public static class HikPlayMethods [DllImport(DllName)] public static extern bool PlayM4_ResetSourceBuffer(int nPort); + /// + /// [新增] 开启硬件解码 + /// + [DllImport(DllName)] + public static extern bool PlayM4_SetHardWareDecode(int nPort, int nMode); + #endregion } \ No newline at end of file diff --git a/SHH.CameraSdk/Drivers/HikVision/HikVideoSource.cs b/SHH.CameraSdk/Drivers/HikVision/HikVideoSource.cs index feb112c..72387f1 100644 --- a/SHH.CameraSdk/Drivers/HikVision/HikVideoSource.cs +++ b/SHH.CameraSdk/Drivers/HikVision/HikVideoSource.cs @@ -54,8 +54,8 @@ public class HikVideoSource : BaseVideoSource, private volatile int _connectionEpoch = 0; // 连接轮询版本号 // 回调委托(强引用防止GC回收) - private HikNativeMethods.REALDATACALLBACK? _realDataCallBack; - private HikPlayMethods.DECCBFUN? _decCallBack; + private readonly HikNativeMethods.REALDATACALLBACK _realDataCallBack; + private readonly HikPlayMethods.DECCBFUN _decCallBack; // 图像处理资源, 内存复用对象 private Mat? _sharedYuvMat; @@ -77,6 +77,11 @@ public class HikVideoSource : BaseVideoSource, _timeProvider = new HikTimeSyncProvider(this); _rebootProvider = new HikRebootProvider(this); _ptzProvider = new HikPtzProvider(this); + + // Modified: [Fix GC Crash] 移除此处的 new REALDATACALLBACK + // 直接使用构造函数初始化的 _realDataCallBack,保证委托地址在整个对象生命周期内不变 + _realDataCallBack = new HikNativeMethods.REALDATACALLBACK(SafeOnRealDataReceived); + _decCallBack = new HikPlayMethods.DECCBFUN(SafeOnDecodingCallBack); } #endregion @@ -424,8 +429,10 @@ public class HikVideoSource : BaseVideoSource, bBlocked = false }; - _realDataCallBack = new HikNativeMethods.REALDATACALLBACK(SafeOnRealDataReceived); _realPlayHandle = HikNativeMethods.NET_DVR_RealPlay_V40(_userId, ref previewInfo, _realDataCallBack, IntPtr.Zero); + + // Optimized: [Fix GC Crash] 显式保活,防止 JIT 在 P/Invoke 过程中激进回收(双重保险) + GC.KeepAlive(_realDataCallBack); return _realPlayHandle >= 0; } @@ -480,8 +487,26 @@ public class HikVideoSource : BaseVideoSource, return; } - _decCallBack = new HikPlayMethods.DECCBFUN(SafeOnDecodingCallBack); + // ================================================================================= + // 🚀 [新增代码] 尝试开启 GPU 硬解码 (1=开启, 0=关闭) + // 位置:必须在 OpenStream 成功之后,SetDecCallBack 之前 + // ================================================================================= + try + { + HikPlayMethods.PlayM4_SetHardWareDecode(_playPort, 1); + _sdkLog.Information($"[Perf] Hik 尝试开启硬解码. ID:{_config.Id} Port:{_playPort}"); + } + catch (Exception ex) + { + // 即使失败也不影响流程,仅记录警告 + _sdkLog.Warning($"[Perf] Hik 开启硬解码失败: {ex.Message}"); + } + HikPlayMethods.PlayM4_SetDecCallBackEx(_playPort, _decCallBack, IntPtr.Zero, 0); + + // Optimized: [Fix GC Crash] 显式保活 + GC.KeepAlive(_decCallBack); + HikPlayMethods.PlayM4_Play(_playPort, IntPtr.Zero); _sdkLog.Debug($"[SDK] Hik 播放端口初始化成功, ID:{_config.Id} IP:{_config.IpAddress} Port:{_config.Port} Name:{_config.Name}, UserID: {_userId}, 播放端口:{_playPort}"); diff --git a/SHH.CameraSdk/SHH.CameraSdk.csproj b/SHH.CameraSdk/SHH.CameraSdk.csproj index bb83a21..c6cf5d1 100644 --- a/SHH.CameraSdk/SHH.CameraSdk.csproj +++ b/SHH.CameraSdk/SHH.CameraSdk.csproj @@ -24,7 +24,7 @@ - + diff --git a/SHH.CameraService/Bootstrapper.cs b/SHH.CameraService/Bootstrapper.cs index a612762..3e9c526 100644 --- a/SHH.CameraService/Bootstrapper.cs +++ b/SHH.CameraService/Bootstrapper.cs @@ -31,16 +31,14 @@ public static class Bootstrapper "--appid", "CameraApp_01", // 视频流地址 (格式: IP,Port,Type,Desc) - "--uris", "localhost,9001,video,调试PC;", - "--uris", "localhost,9002,video,调试PC-2;", - - // 指令通道 - "--uris", "localhost,9001,command,调试PC;", - + "--uris", "localhost,9001,调试PC;", + "--uris", "localhost,9002,调试PC;", + // 日志中心配置 (格式: IP,Port,Desc) "--sequris", "58.216.225.5,20026,日志处置中心;", - "--seqkey", "Shine899195994250;", - + "--seqkey", "Shine978697953780;", + //"--seqkey", "Shine899195994250;", + // 端口策略 "--mode", "1", "--ports", "5000,100" @@ -245,31 +243,71 @@ public static class Bootstrapper if (!config.CommandEndpoints.Any()) return; var gRpcLog = Log.ForContext("SourceContext", LogModules.gRpc); - try + // // Optimized: 并发任务集合,实现多目标同时注册 + var registrationTasks = config.CommandEndpoints.Select(async endpoint => { - // 将 tcp:// 转换为 http:// 以适配 gRpc - string targetUrl = config.CommandEndpoints.First().Uri.Replace("tcp://", "http://"); + string targetUrl = endpoint.Uri.Replace("tcp://", "http://"); - using var channel = GrpcChannel.ForAddress(targetUrl); - var client = new GatewayProvider.GatewayProviderClient(channel); - - gRpcLog.Information($"[gRpc] 正在执行预注册: {targetUrl}"); - var resp = await client.RegisterInstanceAsync(new RegisterRequest + // // Modified: 将 try-catch 移入内部,确保单个端点失败不影响其他端点 + try { - InstanceId = config.AppId, - Version = "2.0.0-grpc", - ServerIp = "127.0.0.1", - WebapiPort = config.BasePort, // 使用扫描后的新端口 - StartTimeTicks = DateTime.Now.Ticks, - ProcessId = Environment.ProcessId, - Description = "" - }); - gRpcLog.Information($"[gRpc] 💡预注册成功: {resp.Message}"); - } - catch (Exception ex) - { - gRpcLog.Error($"[gRpc] ⚠️ 预注册尝试失败: {ex.Message}"); - } + using var channel = GrpcChannel.ForAddress(targetUrl); + var client = new GatewayProvider.GatewayProviderClient(channel); + + gRpcLog.Information($"[gRpc] 正在执行预注册: {targetUrl}"); + + var resp = await client.RegisterInstanceAsync(new RegisterRequest + { + InstanceId = config.AppId, + Version = "2.0.0-grpc", + ServerIp = "127.0.0.1", + WebapiPort = config.BasePort, + StartTimeTicks = DateTime.Now.Ticks, + ProcessId = Environment.ProcessId, + Description = endpoint.Description // 携带备注信息 + }); + + gRpcLog.Information($"[gRpc] 💡预注册成功: {targetUrl} -> {resp.Message}"); + } + catch (Exception ex) + { + // // Optimized: 记录具体哪个端点失败,但不阻断流程 + gRpcLog.Error($"[gRpc] ⚠️ 预注册尝试失败 ({targetUrl}): {ex.Message}"); + } + }); + + // 等待所有注册任务完成 + await Task.WhenAll(registrationTasks); + + //try + //{ + // var cfgEndpoints = config.CommandEndpoints; + // for(var i=0; i /// 命令名称 /// - public string ActionName => ProtocolHeaders.Sync_Camera; + public string ActionName => ProtocolCodes.Sync_Camera; /// /// 构造函数 diff --git a/SHH.CameraService/GrpcImpls/Handlers/DeviceStatusHandler.cs b/SHH.CameraService/GrpcImpls/Handlers/DeviceStatusHandler.cs index d1b32a5..dac13a6 100644 --- a/SHH.CameraService/GrpcImpls/Handlers/DeviceStatusHandler.cs +++ b/SHH.CameraService/GrpcImpls/Handlers/DeviceStatusHandler.cs @@ -24,6 +24,9 @@ public class DeviceStatusHandler : BackgroundService // 状态存储:CameraId -> 状态载荷 private readonly ConcurrentDictionary _stateStore = new(); + // 记录上一次成功发送的状态快照,用于增量日志对比 + private readonly Dictionary _lastPublishedStates = new(); + private volatile bool _isDirty = false; private long _lastSendTick = 0; @@ -40,7 +43,7 @@ public class DeviceStatusHandler : BackgroundService // 1. 初始化本地状态缓存 foreach (var dev in _manager.GetAllDevices()) { - UpdateLocalState(dev.Id, false, "Service Init"); + UpdateLocalState(dev.Id, dev.Config.IpAddress, false, "Service Init"); } // 2. 订阅 SDK 状态变更事件 @@ -71,17 +74,18 @@ public class DeviceStatusHandler : BackgroundService /// /// SDK 状态变更回调 /// - private void OnSdkStatusChanged(long deviceId, bool isOnline, string reason) + private void OnSdkStatusChanged(long deviceId, string ipAddress, bool isOnline, string reason) { - UpdateLocalState(deviceId, isOnline, reason); + UpdateLocalState(deviceId, ipAddress, isOnline, reason); _isDirty = true; } - private void UpdateLocalState(long deviceId, bool isOnline, string reason) + private void UpdateLocalState(long deviceId, string ipAddress, bool isOnline, string reason) { var evt = new StatusEventPayload { CameraId = deviceId.ToString(), + IpAddress = ipAddress, IsOnline = isOnline, Reason = reason, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() @@ -133,8 +137,7 @@ public class DeviceStatusHandler : BackgroundService // 这就是客户端尝试调用的真实路径:/包名.服务名/方法名 var serviceName = client.GetType().DeclaringType?.Name ?? "Unknown"; - _gRpcLog.Debug("[gRpc] 准备调用端点: {Url}", grpcUrl); - _gRpcLog.Debug("[gRpc] 客户端契约服务名: {Service}", serviceName); + _gRpcLog.Debug("[gRpc] 准备调用端点: {Url}, 客户端契约服务名: {Service}", grpcUrl, serviceName); // 执行调用 var response = await client.ReportStatusBatchAsync(request, @@ -142,8 +145,69 @@ public class DeviceStatusHandler : BackgroundService if (response.Success) { - _gRpcLog.Information("[gRpc] 设备状态上报成功, 共计: {Count} 个, Url: {Url}", request.Items.Count, grpcUrl); - _gRpcLog.Debug("[gRpc] 设备状态上报成功: {Url} Items:{Items}", grpcUrl, request.Items); + // 1. 处理变更日志 (Information) + var diffList = new List(); + foreach (var item in request.Items) + { + // 只有状态翻转时才记录变更 + if (!_lastPublishedStates.TryGetValue(item.CameraId, out bool lastStatus) || lastStatus != item.IsOnline) + { + // 从内存 Store 中抓取带有 IP 的原始对象 + _stateStore.TryGetValue(item.CameraId, out var payload); + string ip = payload?.IpAddress ?? "Unknown IP"; + + string statusText = item.IsOnline ? "上线" : "离线"; + diffList.Add($"[{item.CameraId}({ip})] {statusText}"); + + // // Modified: 记录当前状态供下次对比 + _lastPublishedStates[item.CameraId] = item.IsOnline; + } + } + + if (diffList.Any()) + { + _gRpcLog.Information("[gRpc] 设备状态变更: {DiffDetails}, Url: {Url}", + string.Join(", ", diffList), grpcUrl); + } + + // 2. 处理详细统计日志 (Debug) + // Optimized: 通过映射获取 IP,不修改 StatusEventItem 契约 + var onlineDetails = request.Items + .Where(x => x.IsOnline) + .Select(x => { + _stateStore.TryGetValue(x.CameraId, out var p); + return $"{x.CameraId}({p?.IpAddress ?? "N/A"})"; + }).ToList(); + + var offlineDetails = request.Items + .Where(x => !x.IsOnline) + .Select(x => { + _stateStore.TryGetValue(x.CameraId, out var p); + return $"{x.CameraId}({p?.IpAddress ?? "N/A"})"; + }).ToList(); + + var detailParts = new List(); + detailParts.Add($"其中在线 {onlineDetails.Count} 个"); + detailParts.Add($"离线 {offlineDetails.Count} 个"); + + if (offlineDetails.Any()) + { + detailParts.Add($"离线设备【{string.Join(",", offlineDetails)}】"); + } + + if (onlineDetails.Any()) + { + detailParts.Add($"在线设备【{string.Join(",", onlineDetails)}】"); + } + + string detailMsg = string.Join(",", detailParts); + + // // Optimized: 最终输出格式化的详细日志 + _gRpcLog.Debug("[gRpc] 设备状态上报详细: {Url} 总数:{Count} {Detail}", + grpcUrl, + request.Items.Count, + detailMsg); + _isDirty = false; _lastSendTick = Environment.TickCount64; } diff --git a/SHH.CameraService/GrpcImpls/Handlers/RemoveCameraHandler.cs b/SHH.CameraService/GrpcImpls/Handlers/RemoveCameraHandler.cs index 708a9d2..cd2e022 100644 --- a/SHH.CameraService/GrpcImpls/Handlers/RemoveCameraHandler.cs +++ b/SHH.CameraService/GrpcImpls/Handlers/RemoveCameraHandler.cs @@ -18,7 +18,7 @@ namespace SHH.CameraService /// /// 指令名称 /// - public string ActionName => ProtocolHeaders.Remove_Camera; + public string ActionName => ProtocolCodes.Remove_Camera; /// /// 构造函数 diff --git a/SHH.CameraService/Program.cs b/SHH.CameraService/Program.cs index 15539ea..154202a 100644 --- a/SHH.CameraService/Program.cs +++ b/SHH.CameraService/Program.cs @@ -107,6 +107,19 @@ public class Program await manager.StartAsync(); + // 2. Optimized: 主动拉起所有已加载设备的物理连接 + // 理由:当本地配置了 video 推送目标时,不再等待远端 command 下发启动指令 + var allDevices = manager.GetAllDevices(); + foreach (var device in allDevices) + { + if (device.IsRunning && !device.IsActived) + { + logger.Information($"[Core] 🚀 自动激活设备流: ID:{device.Id} IP:{device.Config.IpAddress}"); + // 使用 Fire-and-forget 启动,避免阻塞主线程 + _ = device.StartAsync(); + } + } + var sysLog = Log.ForContext("SourceContext", LogModules.Core); sysLog.Information($"[Core] 🚀 核心业务逻辑已激活, 设备管理器已就绪."); } diff --git a/SHH.CameraService/SHH.CameraService.csproj b/SHH.CameraService/SHH.CameraService.csproj index 7dc1b67..d4add69 100644 --- a/SHH.CameraService/SHH.CameraService.csproj +++ b/SHH.CameraService/SHH.CameraService.csproj @@ -6,6 +6,7 @@ enable enable x64 + notifyIcon.ico @@ -14,6 +15,10 @@ + + + + @@ -26,7 +31,6 @@ - diff --git a/SHH.CameraService/Utils/ServiceCollectionExtensions.cs b/SHH.CameraService/Utils/ServiceCollectionExtensions.cs index cc9e802..21aab4a 100644 --- a/SHH.CameraService/Utils/ServiceCollectionExtensions.cs +++ b/SHH.CameraService/Utils/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.OpenApi.Models; using SHH.CameraSdk; @@ -93,12 +94,24 @@ public static class ServiceCollectionExtensions services.AddSingleton>(netTargets); services.AddHostedService(); - // 动态注册 Sender Worker + //// 动态注册 Sender Worker + //foreach (var target in netTargets) + //{ + // // 注意:这里需要使用 Microsoft.Extensions.Logging.ILogger 来适配构造函数 + // services.AddHostedService(sp => + // new GrpcSenderWorker(target)); + //} + foreach (var target in netTargets) { - // 注意:这里需要使用 Microsoft.Extensions.Logging.ILogger 来适配构造函数 - services.AddHostedService(sp => - new GrpcSenderWorker(target)); + // Modified: 显式声明局部变量,防止 Lambda 捕获循环变量导致的引用重复 + var currentTarget = target; + + logger.Information("[DI] 准备启动 Worker 实例: {Name} -> {Url}", + currentTarget.Config.Name, currentTarget.Config.Endpoint); + + // 使用工厂模式注册,确保传入的是当前的 currentTarget + services.AddSingleton(sp => new GrpcSenderWorker(currentTarget)); } } diff --git a/SHH.CameraService/notifyIcon.ico b/SHH.CameraService/notifyIcon.ico new file mode 100644 index 0000000..01495ab Binary files /dev/null and b/SHH.CameraService/notifyIcon.ico differ diff --git a/SHH.Contracts/CameraConfigDto.cs b/SHH.Contracts.Grpc/Dtos/CameraConfigDto.cs similarity index 52% rename from SHH.Contracts/CameraConfigDto.cs rename to SHH.Contracts.Grpc/Dtos/CameraConfigDto.cs index 3bdaaad..52bb16b 100644 --- a/SHH.Contracts/CameraConfigDto.cs +++ b/SHH.Contracts.Grpc/Dtos/CameraConfigDto.cs @@ -1,5 +1,4 @@ using Newtonsoft.Json; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace SHH.Contracts @@ -11,166 +10,96 @@ namespace SHH.Contracts public class CameraConfigDto { // --- 基础身份 (Identity) --- - /// - /// 设备唯一标识 - /// + + /// 设备唯一标识 [Required(ErrorMessage = "设备ID不能为空")] [Range(1, long.MaxValue, ErrorMessage = "设备ID必须为正整数")] public long Id { get; set; } - /// - /// 设备友好名称 - /// + /// 设备友好名称 [MaxLength(64, ErrorMessage = "设备名称长度不能超过64个字符")] - public string Name { get; set; } + public string Name { get; set; } = string.Empty; - /// - /// 摄像头品牌类型 (0:HikVision, 1:Dahua, 2:RTSP...) - /// + /// 摄像头品牌类型 (0:HikVision, 1:Dahua, 2:RTSP...) [Range(0, 10, ErrorMessage = "品牌类型值必须在0-10范围内")] public int Brand { get; set; } - /// - /// 设备安装位置描述 - /// + /// 设备安装位置描述 [MaxLength(128, ErrorMessage = "安装位置长度不能超过128个字符")] - public string Location { get; set; } + public string Location { get; set; } = string.Empty; // --- 主板关联信息 (Metadata) --- - /// - /// 关联主板IP地址 - /// + /// 关联主板IP地址 [RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)?$", ErrorMessage = "请输入合法的IPv4地址")] public string MainboardIp { get; set; } = string.Empty; - /// - /// 关联主板端口 - /// + /// 关联主板端口 [Range(0, 65535, ErrorMessage = "主板端口号必须在1-65535范围内")] public int MainboardPort { get; set; } = 0; // --- 核心连接 (Connectivity) - 修改此类参数触发冷重启 --- - /// - /// 摄像头IP地址 - /// + /// 摄像头IP地址 [Required(ErrorMessage = "IP地址不能为空")] [RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$", ErrorMessage = "请输入合法的IPv4地址")] - public string IpAddress { get; set; } + public string IpAddress { get; set; } = string.Empty; - /// - /// 登录用户名 - /// + /// 登录用户名 [MaxLength(32, ErrorMessage = "用户名长度不能超过32个字符")] - public string Username { get; set; } + public string Username { get; set; } = string.Empty; - /// - /// 登录密码 - /// + /// 登录密码 [MaxLength(64, ErrorMessage = "密码长度不能超过64个字符")] - public string Password { get; set; } + public string Password { get; set; } = string.Empty; - /// - /// SDK端口 (如海康默认8000) - /// + /// SDK端口 (如海康默认8000) [Range(1, 65535, ErrorMessage = "端口号必须在1-65535范围内")] public ushort Port { get; set; } - /// - /// 通道号 (通常为1) - /// + /// 通道号 (通常为1) [Range(0, 256, ErrorMessage = "通道号必须在0-256范围内")] public int ChannelIndex { get; set; } - /// - /// 码流类型 (0:主码流, 1:子码流) - /// + /// 码流类型 (0:主码流, 1:子码流) [Range(0, 1, ErrorMessage = "码流类型只能是0(主码流)或1(子码流)")] public int StreamType { get; set; } - // 渲染句柄 (通常下发时为0,由本地窗口绑定时再指定,或者此处仅作占位) + /// 渲染句柄 (通常下发时为0,由本地窗口绑定时再指定,或者此处仅作占位) public long RenderHandle { get; set; } - /// - /// RTSP流路径 (备用或非SDK模式使用) - /// + /// RTSP流路径 (备用或非SDK模式使用) [MaxLength(256, ErrorMessage = "RTSP地址长度不能超过256个字符")] - public string RtspPath { get; set; } + public string RtspPath { get; set; } = string.Empty; // --- 运行时参数 (Runtime Options) - 支持热更新 --- - /// - /// 是否使用灰度图 (用于AI分析场景加速) - /// + /// 是否使用灰度图 (用于AI分析场景加速) public bool UseGrayscale { get; set; } = false; - /// - /// 是否启用图像增强 (去噪/锐化等) - /// + /// 是否启用图像增强 (去噪/锐化等) public bool EnhanceImage { get; set; } = true; // --- 画面变换 (Transform) - 支持热更新 --- - /// - /// 是否允许图像压缩 (降低带宽占用) - /// + + /// 是否允许图像压缩 (降低带宽占用) public bool AllowCompress { get; set; } = true; - /// - /// 是否允许图像放大 (提升渲染质量) - /// + /// 是否允许图像放大 (提升渲染质量) public bool AllowExpand { get; set; } = false; - /// - /// 目标分辨率 (格式如 1920x1080,空则保持原图) - /// + /// 目标分辨率 (格式如 1920x1080,空则保持原图) [RegularExpression(@"^\d+x\d+$", ErrorMessage = "分辨率格式必须为 宽度x高度 (如 1920x1080)")] public string TargetResolution { get; set; } = string.Empty; - /// - /// 随配置一并下发的自动订阅请求 - /// + /// 随配置一并下发的自动订阅请求 public List AutoSubscriptions { get; set; } = new List(); - /// - /// 是否立即执行 - /// + /// 是否立即执行 [JsonProperty("ImmediateExecution")] // 确保 JSON 里的这个 key 能精准对应到这个属性 public bool ImmediateExecution { get; set; } } - - /// - /// 订阅项 - /// - public class CameraConfigSubscribeDto - { - /// - /// 订阅标识 - /// - public string AppId { get; set; } - - /// - /// 订阅业务类型 SubscriptionType - /// - public int Type { get; set; } - - /// - /// 要求的帧率:8帧或1帧 - /// - public int TargetFps { get; set; } - - /// - /// 备注 - /// - public string Memo { get; set; } - - /// - /// 是否需要高清晰度 - /// - public bool NeedHighDefinition { get; set; } - = false; - } } \ No newline at end of file diff --git a/SHH.Contracts.Grpc/Dtos/CameraConfigSubscribeDto.cs b/SHH.Contracts.Grpc/Dtos/CameraConfigSubscribeDto.cs new file mode 100644 index 0000000..3b43420 --- /dev/null +++ b/SHH.Contracts.Grpc/Dtos/CameraConfigSubscribeDto.cs @@ -0,0 +1,22 @@ +namespace SHH.Contracts +{ + /// 订阅项 + public class CameraConfigSubscribeDto + { + /// 订阅标识 例如: "UI_Display" (界面显示), "AI_Analysis" (算法分析) + public string AppId { get; set; } = string.Empty; + + /// 订阅业务类型 对应枚举 SubscriptionType 的整型值 + public int Type { get; set; } + + /// 要求的传输帧率 要求的帧率:8 帧或 1 帧 + public int TargetFps { get; set; } + + /// 是否需要高清晰度流(主码流) true: 请求高分辨率主码流; false: 请求低分辨率子码流(默认) + public string Memo { get; set; } = string.Empty; + + /// 是否需要高清晰度 + public bool NeedHighDefinition { get; set; } + = false; + } +} \ No newline at end of file diff --git a/SHH.Contracts/Commands/CommandPayload.cs b/SHH.Contracts.Grpc/Dtos/CommandPayload.cs similarity index 86% rename from SHH.Contracts/Commands/CommandPayload.cs rename to SHH.Contracts.Grpc/Dtos/CommandPayload.cs index 090948e..2aa6253 100644 --- a/SHH.Contracts/Commands/CommandPayload.cs +++ b/SHH.Contracts.Grpc/Dtos/CommandPayload.cs @@ -1,13 +1,9 @@ -using MessagePack; -using System; - -namespace SHH.Contracts +namespace SHH.Contracts { /// /// 通用指令请求载体 (Request) /// 用于 NetMQ 的 Request-Reply 或 Router-Dealer 模式 /// - [MessagePackObject] public class CommandPayload { #region --- 0. 协议自描述 --- @@ -16,7 +12,6 @@ namespace SHH.Contracts /// 协议类型标识 /// 建议值: "COMMAND" 或 "指令包" /// - [Key(0)] public string Protocol { get; set; } = "COMMAND"; #endregion @@ -27,22 +22,19 @@ namespace SHH.Contracts /// 指令代码 (路由键) /// 示例: "PTZ", "RECORD_START", "SERVER_REGISTER" /// - [Key(1)] - public string CmdCode { get; set; } + public string CmdCode { get; set; } = string.Empty; /// /// 目标对象 ID /// 示例: 摄像头ID "101",或者系统级指令填 "SYSTEM" /// - [Key(2)] - public string TargetId { get; set; } + public string TargetId { get; set; } = string.Empty; /// /// 业务参数 (JSON 字符串) /// 根据 CmdCode 的不同,反序列化为不同的 DTO (如 PtzControlDto) /// - [Key(3)] - public string JsonParams { get; set; } + public string JsonParams { get; set; } = string.Empty; #endregion @@ -52,13 +44,11 @@ namespace SHH.Contracts /// 请求追踪 ID (UUID) /// 核心字段:用于实现异步等待 (await)。回执包必须携带此 ID。 /// - [Key(4)] public string RequestId { get; set; } = Guid.NewGuid().ToString("N"); /// /// 发送时间戳 /// - [Key(5)] public DateTime Timestamp { get; set; } = DateTime.Now; #endregion @@ -70,7 +60,6 @@ namespace SHH.Contracts /// true: 发送端会 await 等待结果 (默认) /// false: 发后即忘 (Fire-and-Forget),服务端收到后不回发任何消息,减少带宽 /// - [Key(6)] public bool RequireAck { get; set; } = true; /// @@ -79,14 +68,12 @@ namespace SHH.Contracts /// 1, 2...: 第N次重试 /// 服务端据此判断是否需要查重 (幂等性处理) /// - [Key(7)] public int RetryCount { get; set; } = 0; /// /// 消息过期时间 (Unix时间戳) /// 如果接收端收到时已经超过此时间,直接丢弃,不处理也不回复 /// - [Key(8)] public long ExpireTime { get; set; } #endregion diff --git a/SHH.Contracts/Commands/VideoPayload.cs b/SHH.Contracts.Grpc/Images/VideoPayload.cs similarity index 68% rename from SHH.Contracts/Commands/VideoPayload.cs rename to SHH.Contracts.Grpc/Images/VideoPayload.cs index 80de8cb..ddd4c0b 100644 --- a/SHH.Contracts/Commands/VideoPayload.cs +++ b/SHH.Contracts.Grpc/Images/VideoPayload.cs @@ -1,6 +1,4 @@ -using MessagePack; -using Newtonsoft.Json; -using System.Collections.Generic; +using Newtonsoft.Json; // 注意:如果不想依赖 Newtonsoft,也可以用 System.Text.Json,但 Newtonsoft 在 Std 2.0 中兼容性更好 namespace SHH.Contracts @@ -8,9 +6,11 @@ namespace SHH.Contracts /// /// 视频数据传输契约(纯净版 POCO) /// - [MessagePackObject] public class VideoPayload { + /// + /// 构造函数 + /// public VideoPayload() { SubscriberIds = new List(16); @@ -19,49 +19,40 @@ namespace SHH.Contracts #region --- 1. 元数据 (Metadata) --- - [Key(0)] - public string CameraId { get; set; } + public string CameraId { get; set; } = string.Empty; - /// - /// 采集时间戳 (Unix 毫秒) - /// - [Key(1)] + /// 采集时间戳 (Unix 毫秒) public long CaptureTimestamp { get; set; } - /// - /// 分发时间戳 (Unix 毫秒) - /// - [Key(2)] + /// 分发时间戳 (Unix 毫秒) public long DispatchTimestamp { get; set; } - [Key(3)] + /// 原始图像宽度 public int OriginalWidth { get; set; } - [Key(4)] + /// 原始图像高度 public int OriginalHeight { get; set; } - [Key(5)] + /// 目标图像宽度 public int TargetWidth { get; set; } - [Key(6)] + /// 目标图像高度 public int TargetHeight { get; set; } - [Key(7)] + /// 订阅Ids public List SubscriberIds { get; set; } - [Key(8)] + /// 诊断信息 public Dictionary Diagnostics { get; set; } /// /// 指示标志:是否存在原始图 /// - [Key(9)] public bool HasOriginalImage { get; set; } /// /// 指示标志:是否存在处理图 /// - [Key(10)] public bool HasTargetImage { get; set; } #endregion @@ -70,12 +61,10 @@ namespace SHH.Contracts // 标记 JsonIgnore,防止被错误序列化 [JsonIgnore] - [IgnoreMember] - public byte[] OriginalImageBytes { get; set; } + public byte[]? OriginalImageBytes { get; set; } [JsonIgnore] - [IgnoreMember] - public byte[] TargetImageBytes { get; set; } + public byte[]? TargetImageBytes { get; set; } #endregion @@ -87,13 +76,13 @@ namespace SHH.Contracts public string GetMetadataJson() { // 在序列化前自动更新标志位,防止逻辑不同步 - this.HasOriginalImage = (OriginalImageBytes != null && OriginalImageBytes.Length > 0); - this.HasTargetImage = (TargetImageBytes != null && TargetImageBytes.Length > 0); + HasOriginalImage = (OriginalImageBytes != null && OriginalImageBytes.Length > 0); + HasTargetImage = (TargetImageBytes != null && TargetImageBytes.Length > 0); return JsonConvert.SerializeObject(this); } - public static VideoPayload FromMetadataJson(string json) + public static VideoPayload? FromMetadataJson(string json) { return JsonConvert.DeserializeObject(json); } diff --git a/SHH.Contracts.Grpc/Payloads/ProtocolCodes.cs b/SHH.Contracts.Grpc/Payloads/ProtocolCodes.cs new file mode 100644 index 0000000..d908855 --- /dev/null +++ b/SHH.Contracts.Grpc/Payloads/ProtocolCodes.cs @@ -0,0 +1,47 @@ +namespace SHH.Contracts +{ + /// + /// 协议代码定义常量类 + /// 职责:统一管理 gRpc 通讯中所涉及的协议大类 (Protocol) 与具体业务指令码 (CmdCode) + /// + public static class ProtocolCodes + { + #region --- 1. 协议类型定义 (对应 Protocol 字段) --- + + /// + /// 基础指令协议头 + /// 用于标记该消息是一个业务控制指令 + /// + public const string Command = "Command"; + + /// + /// 指令执行结果反馈协议头 + /// 用于分析节点执行完指令后,向主控端回执操作结果 + /// + public const string Command_Result = "Command_Result"; + + #endregion + + #region --- 2. 业务指令码定义 (对应 CmdCode 字段) --- + + /// + /// 服务器注册指令 + /// 触发场景:节点启动时向主控端注册自身信息 + /// + public const string ServerRegister = "SERVER_REGISTER"; + + /// + /// 同步摄像头配置指令 + /// 触发场景:节点上线全量同步、数据库摄像头信息变更增量同步 + /// + public static string Sync_Camera { get; } = "Sync_Camera"; + + /// + /// 移除摄像头指令 + /// 触发场景:本地数据库删除摄像头后,通知远程节点停止相关流采集与分析 + /// + public static string Remove_Camera { get; } = "Remove_Camera"; + + #endregion + } +} \ No newline at end of file diff --git a/SHH.Contracts/Commands/RegisterPayload.cs b/SHH.Contracts.Grpc/Payloads/RegisterPayload.cs similarity index 50% rename from SHH.Contracts/Commands/RegisterPayload.cs rename to SHH.Contracts.Grpc/Payloads/RegisterPayload.cs index 23ce45b..e79d9a6 100644 --- a/SHH.Contracts/Commands/RegisterPayload.cs +++ b/SHH.Contracts.Grpc/Payloads/RegisterPayload.cs @@ -1,44 +1,33 @@ -using MessagePack; -using System; - -namespace SHH.Contracts +namespace SHH.Contracts { /// /// 服务端身份注册信息 (DTO) /// 用于服务端主动连上客户端后,上报自身的端口和身份信息 /// - [MessagePackObject] public class RegisterPayload { #region --- 0. 协议自描述 --- - /// - /// 协议类型标识 (人工可读) - /// - [Key(0)] - public string Protocol { get; set; } = ProtocolHeaders.ServerRegister; + /// 协议类型标识 (人工可读) + public string Protocol { get; set; } = ProtocolCodes.ServerRegister; #endregion #region --- 1. 身份标识 --- - /// - /// 进程 ID (用于区分同一台机器上的多个实例) - /// - [Key(1)] + /// 进程 ID (用于区分同一台机器上的多个实例) public int ProcessId { get; set; } + /// 调用进程 ID (用于区分同一台机器上的多个实例) + public int InvokeProcId { get; set; } + /// /// 实例唯一标识符 /// 启动时通过命令行传入,例如 "Gateway_Factory_A" /// - [Key(2)] - public string InstanceId { get; set; } + public string InstanceId { get; set; } = string.Empty; - /// - /// 服务端版本号 - /// - [Key(3)] + /// 服务端版本号 public string Version { get; set; } = "1.0.0"; #endregion @@ -49,43 +38,26 @@ namespace SHH.Contracts /// 服务端所在的局域网 IP /// 客户端无法直接连接此IP(因为可能是内网),但运维人员需要知道 /// - [Key(4)] - public string ServerIp { get; set; } + public string ServerIp { get; set; } = string.Empty; /// /// WebAPI 监听端口 (HTTP) /// 用于运维人员打开 Swagger 进行调试 /// - [Key(5)] public int WebApiPort { get; set; } - /// - /// 视频流端口 (ZeroMQ Publisher/Push) - /// - [Key(6)] - public int VideoPort { get; set; } - - /// - /// 指令流端口 (ZeroMQ Response) - /// - [Key(7)] - public int CmdPort { get; set; } + /// Grpc通讯端口 + public int GrpcPort { get; set; } #endregion #region --- 3. 运行时状态 --- - /// - /// 启动时间 - /// - [Key(8)] + /// 启动时间 public DateTime StartTime { get; set; } - /// - /// 描述信息 (可选) - /// - [Key(9)] - public string Description { get; set; } + /// 描述信息 (可选) + public string Description { get; set; } = string.Empty; #endregion } diff --git a/SHH.Contracts.Grpc/Payloads/StatusEventPayload.cs b/SHH.Contracts.Grpc/Payloads/StatusEventPayload.cs new file mode 100644 index 0000000..32e392c --- /dev/null +++ b/SHH.Contracts.Grpc/Payloads/StatusEventPayload.cs @@ -0,0 +1,23 @@ +namespace SHH.Contracts +{ + /// + /// [控制面] 设备状态变更通知包 + /// + public class StatusEventPayload + { + /// 摄像头ID + public string CameraId { get; set; } = string.Empty; + + /// IP地址 + public string IpAddress { get; set; } = string.Empty; + + /// true: 上线/活跃, false: 离线/超时 + public bool IsOnline { get; set; } + + /// 变更原因 (e.g. "Ping Success", "Frame Timeout") + public string Reason { get; set; } = string.Empty; + + /// 时间戳 + public long Timestamp { get; set; } + } +} \ No newline at end of file diff --git a/SHH.Contracts.Grpc/SHH.Contracts.Grpc.csproj b/SHH.Contracts.Grpc/SHH.Contracts.Grpc.csproj index 503e95e..bc9bf7e 100644 --- a/SHH.Contracts.Grpc/SHH.Contracts.Grpc.csproj +++ b/SHH.Contracts.Grpc/SHH.Contracts.Grpc.csproj @@ -23,6 +23,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/SHH.Contracts/CommandResult.cs b/SHH.Contracts/CommandResult.cs deleted file mode 100644 index 971bffc..0000000 --- a/SHH.Contracts/CommandResult.cs +++ /dev/null @@ -1,86 +0,0 @@ -using MessagePack; - -namespace SHH.Contracts -{ - /// - /// 通用指令执行结果 (Response) - /// - [MessagePackObject] - public class CommandResult - { - #region --- 0. 协议自描述 --- - - [Key(0)] - public string Protocol { get; set; } = "COMMAND_RESULT"; - - #endregion - - #region --- 核心匹配信息 --- - - /// - /// 回执 ID (必须与请求包的 RequestId 一致) - /// 客户端靠这个 ID 来找到对应的 await Task - /// - [Key(1)] - public string RequestId { get; set; } - - #endregion - - #region --- 执行结果 --- - - /// - /// 执行是否成功 - /// - [Key(2)] - public bool Success { get; set; } - - /// - /// 结果消息 (成功提示或错误原因) - /// - [Key(3)] - public string Message { get; set; } - - /// - /// 返回的数据 (JSON 或 Base64 字符串) - /// 示例: 截图的 Base64,或者查询到的设备列表 JSON - /// - [Key(4)] - public string Data { get; set; } - - #endregion - - #region --- 性能统计 --- - - /// - /// 全链路耗时 (毫秒) - /// 从客户端发出指令,到收到服务端回执的总时长 - /// 注意:该字段由客户端收到回执后自动计算填充,服务端不需要赋值 - /// - [Key(5)] - public double ElapsedMilliseconds { get; set; } - - #endregion - - /// - /// 时间戳 - /// - [Key(6)] - public long Timestamp { get; set;} - - #region --- 快捷构造方法 --- - - /// - /// 快速创建一个成功的回执 - /// - public static CommandResult Ok(string msg = "OK", string data = null) - => new CommandResult { Success = true, Message = msg, Data = data }; - - /// - /// 快速创建一个失败的回执 - /// - public static CommandResult Fail(string msg) - => new CommandResult { Success = false, Message = msg }; - - #endregion - } -} \ No newline at end of file diff --git a/SHH.Contracts/Commands/DeviceStatusEvent.cs b/SHH.Contracts/Commands/DeviceStatusEvent.cs deleted file mode 100644 index cbde5d3..0000000 --- a/SHH.Contracts/Commands/DeviceStatusEvent.cs +++ /dev/null @@ -1,49 +0,0 @@ -using MessagePack; -using System.Collections.Generic; - -namespace SHH.Contracts -{ - /// - /// [控制面] 状态全量快照包 - /// - [MessagePackObject] - public class StatusBatchPayload - { - // [新增] 协议类型标识 (人工可读) - // 建议值: "STATUS_BATCH" 或 "设备状态全量包" - [Key(0)] - public string Protocol { get; set; } = "STATUS_BATCH"; - - [Key(1)] - public List Items { get; set; } - = new List(); - - [Key(2)] - public long Timestamp { get; set; } - } - - /// - /// [控制面] 设备状态变更通知包 - /// - [MessagePackObject] - public class StatusEventPayload - { - [Key(0)] - public string CameraId { get; set; } - - /// - /// true: 上线/活跃, false: 离线/超时 - /// - [Key(1)] - public bool IsOnline { get; set; } - - /// - /// 变更原因 (e.g. "Ping Success", "Frame Timeout") - /// - [Key(2)] - public string Reason { get; set; } - - [Key(3)] - public long Timestamp { get; set; } - } -} \ No newline at end of file diff --git a/SHH.Contracts/Commands/ProtocolHeaders.cs b/SHH.Contracts/Commands/ProtocolHeaders.cs deleted file mode 100644 index 7379478..0000000 --- a/SHH.Contracts/Commands/ProtocolHeaders.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace SHH.Contracts -{ - public static class ProtocolHeaders - { - // 核心协议头定义 - public const string ServerRegister = "SERVER_REGISTER"; - public const string StatusBatch = "STATUS_BATCH"; - public const string Command = "COMMAND"; - public const string CommandResult = "COMMAND_RESULT"; - - public const string Sync_Camera = "Sync_Camera"; - - public const string Remove_Camera = "Remove_Camera"; - } -} \ No newline at end of file diff --git a/SHH.Contracts/SHH.Contracts.csproj b/SHH.Contracts/SHH.Contracts.csproj deleted file mode 100644 index 57dc38e..0000000 --- a/SHH.Contracts/SHH.Contracts.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - netstandard2.0 - - - - - - - - - diff --git a/SHH.MjpegPlayer/Bootstrapper.cs b/SHH.MjpegPlayer/Bootstrapper.cs new file mode 100644 index 0000000..b79e12a --- /dev/null +++ b/SHH.MjpegPlayer/Bootstrapper.cs @@ -0,0 +1,172 @@ +using Ayay.SerilogLogs; +using CoreWCF; +using CoreWCF.Configuration; +using CoreWCF.Description; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Player.MJPEG; +using Serilog; +using System.Diagnostics; +using System.Net; + +namespace SHH.MjpegPlayer +{ + public static class Bootstrapper + { + private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core); + + #region LoadConfig + + /// + /// 加载配置文件 + /// + /// + public static MjpegConfig LoadConfig() + { + // [修复] 路径处理脆弱性:使用 BaseDirectory 拼接,避免相对路径替换的风险 + // 生产环境:强制使用绝对路径确保能找到配置文件 + if (!Debugger.IsAttached) + { + JsonConfigUris.MjpegConfig = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Path.GetFileName(JsonConfigUris.MjpegConfig)); + } + + // 加载配置文件 + var cfg = JsonConfig.Load(JsonConfigUris.MjpegConfig); + if (cfg == null) + { + cfg = new MjpegConfig(); + JsonConfig.Save(cfg, JsonConfigUris.MjpegConfig, "MjpegServer配置项"); + _sysLog.Warning("未找到配置文件,已生成默认配置: {Path}", JsonConfigUris.MjpegConfig); + } + MjpegStatics.Cfg = cfg; + return cfg; + } + + #endregion + + #region ValidateEnvironment + + /// + /// 检查 IP 与端口 + /// + public static void ValidateEnvironment() + { + var cfg = MjpegStatics.Cfg; + + // IP 地址检查 + IPAddress? ipAddress; + if (!IPAddress.TryParse(cfg.SvrMjpegIp, out ipAddress)) + { + ipAddress = IPAddress.Any; + _sysLog.Warning("配置的 IP 地址非法,将使用 IPAddress. Any: {Ip}.", cfg.SvrMjpegIp); + } + + // 端口检查 => Wcf 接收图片接口 + var portsToCheck = new List { cfg.WcfPushImagePort, cfg.SvrMjpegPortBegin, cfg.SvrMjpegPortEnd }; + foreach (var port in portsToCheck) + { + if (!port.IsServerPort()) + { + _sysLog.Error("端口配置无效, Port: {Port}.", port); + ExitApp($"端口配置无效, Port: {port}"); + } + } + + // 端口检查 => Mjpeg 服务端口 + if (!cfg.SvrMjpegPortBegin.IsServerPort()) + { + _sysLog.Fatal("WCF 接收端口被占用, Port:{Port}.", cfg.WcfPushImagePort); + // 退出应用 + ExitApp("端口占用."); + } + + // [修复] 循环逻辑错误:将 < 改为 <=,确保最后一个端口也被检测 + for (var i = cfg.SvrMjpegPortBegin; i <= cfg.SvrMjpegPortEnd; i++) + { + if (!i.PortOccupiedProc()) + { + // 退出应用 + _sysLog.Fatal("MJPEG 监听端口被占用, Port:{Port}", i); + ExitApp($"MJPEG 监听端口被占用, Port:{i}"); + } + } + } + + #endregion + + #region StartWcfEngine + + /// + /// 内部 WCF 引擎初始化 (CoreWCF) + /// + public static void StartWcfEngine(MjpegConfig cfg) + { + // Optimized: 内存监控提升 + MemoryWatchdog.Start(300, 2048); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls($"http://*:{cfg.WcfPushImagePort}"); + + // 托管日志 + builder.Host.UseSerilog(_sysLog); + + builder.Services.AddServiceModelServices(); + builder.Services.AddServiceModelMetadata(); + builder.Services.AddSingleton() + .AddSingleton(); + + var app = builder.Build(); + + var wsBinding = new WSHttpBinding(SecurityMode.None); + wsBinding.MaxReceivedMessageSize = cfg.SvrPushImageMaxRecMsgSize; + + // Modified: [原因] 强制转换 IApplicationBuilder 修复 UseServiceModel 的二义性 + ((IApplicationBuilder)app).UseServiceModel(serviceBuilder => + { + serviceBuilder.AddService(opt => + { + opt.BaseAddresses.Add(new Uri($"http://0.0.0.0:{cfg.WcfPushImagePort}")); + }) + .AddServiceEndpoint( + wsBinding, + $"/{cfg.SvrNamePushImage}", + new Uri($"http://0.0.0.0:{cfg.WcfPushImagePort}/{cfg.SvrNamePushImage}") + ); + }); + + // 关闭元数据暴露增强安全性 + var meta = app.Services.GetRequiredService(); + meta.HttpGetEnabled = false; + meta.HttpsGetEnabled = false; + + Task.Run(() => app.Run()); + } + + #endregion + + #region ExitApp + + /// + /// 应用程序退出 + /// + /// + /// + public static void ExitApp(string exitMsg, int waitSeconds = 5) + { + // [修复] 尝试停止所有 MjpegServer 监听 + try { MjpegServer.StopAll(); } catch { } + + var iSleep = waitSeconds * 2; + for (var i = 0; i < iSleep; i++) + { + Thread.Sleep(500); + } + + // 退出程序 + Environment.Exit(0); + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Extensions/NetHttpExtension.cs b/SHH.MjpegPlayer/Core/Extensions/NetHttpExtension.cs new file mode 100644 index 0000000..b5c3995 --- /dev/null +++ b/SHH.MjpegPlayer/Core/Extensions/NetHttpExtension.cs @@ -0,0 +1,120 @@ +using Ayay.SerilogLogs; +using Newtonsoft.Json; +using Serilog; +using System.Text; + +namespace SHH.MjpegPlayer; + +/// +/// 扩展 HttpClient 的 PostJson 方法,用于发送 JSON 格式的数据 +/// +public static class NetHttpExtension +{ + // Optimized: 统一日志对象 + private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core); + + // Optimized: 使用静态单例 HttpClient 防止套接字耗尽。注意:生产环境建议配合 SocketsHttpHandler + private static readonly HttpClient _httpClient = new HttpClient(); + + #region 同步方法 (Sync-over-Async, 谨慎使用) + + /// + /// 发送 JSON 格式的 POST 请求 (同步) + /// + public static string PostJson(this object jsonData, string url, int timeout = 2000) + { + try + { + // Optimized: 显式调用异步版本并等待,注意在某些上下文可能死锁 + return PostJsonAsync(jsonData, url, timeout).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _sysLog.Error(ex, "Post 同步请求异常: {Url}", url); + return string.Empty; + } + } + + /// + /// 发送 JSON 格式的 POST 请求并反序列化 (同步) + /// + public static T? PostJson(this object jsonData, string url, int timeout = 2000) + { + try + { + var msg = PostJson(jsonData, url, timeout); + return string.IsNullOrWhiteSpace(msg) ? default : JsonConvert.DeserializeObject(msg); + } + catch (Exception ex) + { + _sysLog.Error(ex, "Post 同步请求并解析 JSON 异常: {Url}", url); + return default; + } + } + + #endregion + + #region 异步方法 (推荐使用) + + /// + /// 发送 JSON 格式的 POST 请求 (异步) + /// + /// 要发送的对象 + /// 目标地址 + /// 超时(ms) + public static async Task PostJsonAsync(this object jsonData, string url, int timeout = 2000) + { + string jsonString = string.Empty; + try + { + // Optimized: 序列化处理 + jsonString = jsonData is string s ? s : JsonConvert.SerializeObject(jsonData); + using var content = new StringContent(jsonString, Encoding.UTF8, "application/json"); + + // Optimized: 设置请求级别的超时处理(HttpClient.Timeout 是全局的,此处利用 CancellationTokenSource) + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeout)); + + var response = await _httpClient.PostAsync(url, content, cts.Token); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync(); + } + + _sysLog.Warning("Post 请求状态异常: {Url}, StatusCode: {Code}", url, response.StatusCode); + return string.Empty; + } + catch (OperationCanceledException) + { + _sysLog.Warning("Post 请求超时: {Url}, Timeout: {Timeout}ms", url, timeout); + return string.Empty; + } + catch (Exception ex) + { + // Modified: 使用结构化日志记录错误 + _sysLog.Error(ex, "Post 异步请求发生故障: {Url}", url); + return string.Empty; + } + } + + /// + /// 发送 JSON 格式的 POST 请求并反序列化 (异步) + /// + public static async Task PostJsonAsync(this object jsonData, string url, int timeout = 2000) + { + try + { + var result = await PostJsonAsync(jsonData, url, timeout); + if (string.IsNullOrWhiteSpace(result)) return default; + + return JsonConvert.DeserializeObject(result); + } + catch (Exception ex) + { + _sysLog.Error(ex, "Post 异步请求解析 JSON 失败: {Url}", url); + return default; + } + } + + #endregion +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Extensions/NetPortExtension.cs b/SHH.MjpegPlayer/Core/Extensions/NetPortExtension.cs new file mode 100644 index 0000000..ffc684b --- /dev/null +++ b/SHH.MjpegPlayer/Core/Extensions/NetPortExtension.cs @@ -0,0 +1,190 @@ +using Ayay.SerilogLogs; +using Serilog; +using System.Diagnostics; +using System.Net; +using System.Net.NetworkInformation; +using System.Text.RegularExpressions; + +namespace SHH.MjpegPlayer +{ + /// + /// 网口占用检测 + /// + public static class NetPortExtension + { + private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core); + + #region IsServerPort + + /// + /// 是否端口 + /// + /// + /// + public static bool IsServerPort(this int value) + { + if (value > 0 && value < 65535) + return true; + + return false; + } + + #endregion + + #region IsPortOccupied + + /// + /// 端口占用检测 + /// + /// + /// + public static bool IsPortOccupied(this int port) + { + var ipProperties = IPGlobalProperties.GetIPGlobalProperties(); + IPEndPoint[] activeListeners = ipProperties.GetActiveTcpListeners(); + foreach (var endPoint in activeListeners) + { + if (endPoint.Port == port) + return true; // 端口被占用 + } + + return false; // 端口可用 + } + + #endregion + + #region GetProcessIdByPort + + /// + /// 查询端口占用进程 Pid + /// + /// + /// + public static int GetProcessIdByPort(this int port) + { + try + { + using (Process proc = new Process()) + { + proc.StartInfo.FileName = "cmd.exe"; + proc.StartInfo.Arguments = $"/c netstat -ano | findstr :{port}"; + proc.StartInfo.UseShellExecute = false; + proc.StartInfo.RedirectStandardOutput = true; + proc.StartInfo.CreateNoWindow = true; + + proc.Start(); + string output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + + // 解析输出(示例:TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234) + Match match = Regex.Match(output, @":\d+\s+.*?LISTENING\s+(\d+)"); + if (match.Success && int.TryParse(match.Groups[1].Value, out int pid)) + return pid; + } + + return 0; + } + catch (Exception ex) + { + _sysLog.Warning("查询端口占用进程出错", ex.Message, ex.StackTrace); + return 0; + } + } + + #endregion + + #region GetProcessIdByPort + + /// + /// 查询端口占用进程 Pid + /// + /// + /// + public static string GetProcessNameIdByPort(this int port) + { + try + { + using (Process proc = new Process()) + { + proc.StartInfo.FileName = "cmd.exe"; + proc.StartInfo.Arguments = $"/c netstat -ano | findstr :{port}"; + proc.StartInfo.UseShellExecute = false; + proc.StartInfo.RedirectStandardOutput = true; + proc.StartInfo.CreateNoWindow = true; + + proc.Start(); + string output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + + // 解析输出(示例:TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234) + Match match = Regex.Match(output, @":\d+\s+.*?LISTENING\s+(\d+)"); + if (match.Success && int.TryParse(match.Groups[1].Value, out int pid)) + { + using (Process process = Process.GetProcessById(pid)) + return process.ProcessName; + } + } + + return string.Empty; + } + catch (Exception ex) + { + _sysLog.Warning($"查询端口占用进程出错, 错误信息:{ex.Message} {ex.StackTrace}"); + return string.Empty; + } + } + + #endregion + + #region PortOccupiedProc + + /// + /// 端口占用检测并杀掉进程 + /// + /// + /// 返回占用端口清理结果 + public static bool PortOccupiedProc(this int port) + { + if (port.IsPortOccupied()) + { + _sysLog.Warning("服务器端口被占用, Port: {port}"); + + // 等待 5 秒 + for (var i = 0; i < 10; i++) + Thread.Sleep(500); + + // 查找占用端口的进程 + var pid = port.GetProcessIdByPort(); + if (pid != 0) + { + // 获取进程名 + string procName = pid.GetProcessName(); + + // 找到占用端口的进程 + _sysLog.Warning($"找到占用端口进程 Pid: {pid} 进程名:{procName}, 5 秒后即将尝试杀掉占用端口的进程."); + + // 等待 5 秒 + for (var i = 0; i < 10; i++) + Thread.Sleep(500); + + // 杀掉指定进程 + if (!pid.KillProcessByPid(procName)) + { + // 退出应用 + return false; + } + + // 等待 2 秒 + Thread.Sleep(2000); + return true; + } + + return false; + } + + return true; + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Extensions/ProcessExtension.cs b/SHH.MjpegPlayer/Core/Extensions/ProcessExtension.cs new file mode 100644 index 0000000..427d357 --- /dev/null +++ b/SHH.MjpegPlayer/Core/Extensions/ProcessExtension.cs @@ -0,0 +1,197 @@ +using Ayay.SerilogLogs; +using Serilog; +using System.Diagnostics; + +namespace SHH.MjpegPlayer +{ + /// + /// 进程扩展 + /// + public static class ProcessExtension + { + private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core); + + #region GetProcessName + + /// + /// 获取进程名称 + /// + /// + /// + public static string GetProcessName(this int pid) + { + try + { + var process = Process.GetProcessById(pid); + return process.ProcessName; + } + catch (Exception ex) + { + _sysLog.Error(ex, "查询进程名出错, Pid: {Pid}", pid); + return string.Empty; + } + } + + #endregion + + #region KillProcessByPid + + /// + /// 杀掉进程 + /// + /// + /// + /// + public static bool KillProcessByPid(this int pid, string procName = "") + { + try + { + var process = Process.GetProcessById(pid); + + if (process != null) + { + procName = process.ProcessName; + process.Kill(); + + _sysLog.Warning("拒绝停止高权限系统进程: {Pid} - {Name}", pid, process.ProcessName); + return true; + } + else + { + // 找不到 ID 对应的进程,应该是进异常不会进这里 + _sysLog.Information("成功杀掉进程 - Pid: {Pid}", pid); + return false; + } + } + catch (ArgumentException) + { + _sysLog.Warning("杀掉进程失败,Pid: {Pid} 不存在", pid); + return false; + } + catch (Exception ex) + { + _sysLog.Error(ex, "杀掉进程异常, Pid: {Pid}", pid); + return false; + } + } + + #endregion + + #region KillProcessByName + + /// + /// 杀掉进程 + /// + /// + /// + /// + public static int KillProcessByName(this string procName) + { + if (string.IsNullOrWhiteSpace(procName)) return 0; + + int killCount = 0; + try + { + var processes = Process.GetProcessesByName(procName); + foreach (var proc in processes) + { + using (proc) // Optimized: 确保 Process 资源被释放 + { + try + { + if (proc.IsHighPrivilegeProcess()) continue; + + int currentId = proc.Id; + proc.Kill(); + killCount++; + _sysLog.Information("成功通过名称杀掉进程 - Pid: {Pid}, Name: {Name}", currentId, procName); + } + catch (Exception ex) + { + _sysLog.Error(ex, "通过名称杀掉单个进程失败: {Name}", procName); + } + } + } + return killCount; + } + catch (Exception ex) + { + _sysLog.Error(ex, "通过名称杀掉进程列表异常: {Name}", procName); + return 0; + } + } + + #endregion + + #region StartProcess + + /// + /// 开启进程 + /// + /// + public static bool StartProcess(this string procPath) + { + try + { + if (!File.Exists(procPath)) + { + _sysLog.Error("启动进程失败,路径不存在: {Path}", procPath); + return false; + } + + // Optimized: 显式记录启动行为 + var process = Process.Start(procPath); + if (process != null) + { + _sysLog.Information("进程启动成功: {Path}, Pid: {Pid}", procPath, process.Id); + return true; + } + return false; + } + catch (Exception ex) + { + _sysLog.Error(ex, "启动进程异常: {Path}", procPath); + return false; + } + } + + #endregion + + #region IsHighPrivilegeProcess + + /// + /// 检测是否高权限等级 + /// + /// + /// + public static bool IsHighPrivilegeProcess(this Process proc) + { + // 典型的高权限进程列表(可根据实际需求扩展) + string[] highPrivilegeProcesses = new[] { + "System", "smss.exe", "csrss.exe", "wininit.exe", "services.exe", + "lsass.exe", "winlogon.exe", "spoolsv.exe", "svchost.exe", + "csrss", "msedge" + }; + + // 检查进程名称是否在高权限列表中 + foreach (string name in highPrivilegeProcesses) + { + if (proc.ProcessName.Equals(name, StringComparison.OrdinalIgnoreCase)) + return true; + } + + // 检查进程是否属于系统会话(Session 0) + try + { + return proc.SessionId == 0; + } + catch + { + // 如果无法获取 SessionId,保守返回 true + return true; + } + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/ImageChannel.cs b/SHH.MjpegPlayer/Core/ImageChannel.cs new file mode 100644 index 0000000..acafd1a --- /dev/null +++ b/SHH.MjpegPlayer/Core/ImageChannel.cs @@ -0,0 +1,61 @@ +namespace SHH.MjpegPlayer +{ + /// 图片通道 + public class ImageChannel + { + /// 进程 ID + public Int32 ProcId { get; set; } + + /// 设备 ID + public Int64 DeviceId { get; set; } + + /// 设备 IP + public string IpAddress { get; set; } = string.Empty; + + /// 名称 + public string Name { get; set; } = string.Empty; + + /// 类型 + public string Type { get; set; } = string.Empty; + + /// 图像宽度 + public int ImageWidth { get; set; } + + /// 图像高度 + public int ImageHeight { get; set; } + + /// 更新时间 + public DateTime UpdateTime { get; set; } + + /// 是否正在播放 + public bool IsPlaying { get; set; } + + /// 是否需要推流到 Rtmp 服务器 + public bool UseRtmp { get; set; } = true; + + #region RtmpUri + + private string _rtmpUri = string.Empty; + + /// Rtmp 推流地址 + public string RtmpUri + { + get => _rtmpUri; + set + { + if (_rtmpUri == value) + return; + _rtmpUri = value; + } + } + + #endregion + + #region TestUri + + /// 测试地址 + public string TestUri => $"?id={DeviceId}&typeCode={Type}"; + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/ImageChannels.cs b/SHH.MjpegPlayer/Core/ImageChannels.cs new file mode 100644 index 0000000..e899b7f --- /dev/null +++ b/SHH.MjpegPlayer/Core/ImageChannels.cs @@ -0,0 +1,72 @@ +using Core.WcfProtocol; +using System.Collections.Concurrent; + +namespace SHH.MjpegPlayer +{ + /// 图片通道集合 + public class ImageChannels + { + #region Channels + + /// + /// 通道信息 (线程安全版本) + /// + // [修复] 使用 ConcurrentDictionary 替代 Dictionary,防止多线程读写(如推流和接收图片同时进行)时崩溃 + public ConcurrentDictionary Channels { get; set; } + = new ConcurrentDictionary(); + + #endregion + + #region Do + + /// + /// 处置图片 + /// + /// + /// + public ImageChannel? Do(UploadImageRequest req, string key) + { + // [修复] 使用 GetOrAdd 原子操作,无需 lock,彻底解决并发冲突 + // 如果 key 不存在,则创建新通道;如果存在,则返回现有通道 + var chn = Channels.GetOrAdd(key, k => new ImageChannel + { + DeviceId = req.Id, + Name = req.Name, + Type = req.Type, + }); + + // 更新指定信息 (直接属性赋值是原子性的,无需锁) + chn.IpAddress = req.IpAddress; + chn.ProcId = req.ProcId; + chn.ImageWidth = req.ImageWidth; + chn.ImageHeight = req.ImageHeight; + chn.UpdateTime = req.Time; + + return chn; + } + + #endregion + + #region Get + + /// + /// 获取通道信息 + /// + /// + /// + /// + public ImageChannel? Get(string deviceId, string aiTypeCode) + { + string key = $"{deviceId}#{aiTypeCode}"; + + // [修复] ConcurrentDictionary 读取原本就是线程安全的 + if (Channels.TryGetValue(key, out var val)) + { + return val; + } + return null; + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/JsonConfig.cs b/SHH.MjpegPlayer/Core/JsonConfig.cs new file mode 100644 index 0000000..2b3533d --- /dev/null +++ b/SHH.MjpegPlayer/Core/JsonConfig.cs @@ -0,0 +1,94 @@ +using Newtonsoft.Json; + +namespace SHH.MjpegPlayer; + +/// +/// Json 配置文件 +/// +public class JsonConfig +{ + #region Load + + /// + /// 加载配置 + /// + /// + /// + /// + public static T? Load(string path) + { + try + { + var newPath = $"{Environment.CurrentDirectory}\\{path}"; + path = newPath.Replace("Res\\Plugins\\", ""); + var sr = new StreamReader(path); + var data = sr.ReadToEnd(); + sr.Close(); + sr = null; + + data = data.Replace(@"""$schema"": ""https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json"",", ""); + + var obj = JsonConvert.DeserializeObject(data); + //Logs.LogInformation(EIdFiles.LoadSucceed, + // $"配置{EIdFiles.LoadSucceed.GetDescription()}, Path:{path} 类型:{typeof(T).FullName}."); + return obj; + } + catch (Exception ex) + { + //Logs.LogWarning(EIdFiles.LoadFailed, + // $"配置{EIdFiles.LoadSucceed.GetDescription()}, Path:{path} 类型:{typeof(T).FullName}.", ex.Message, ex.StackTrace); + return default(T); + } + } + + #endregion + + #region Save + + /// + /// 保存配置 + /// + /// + /// + /// + /// + public static bool Save(object obj, string path, string caption) + { + try + { + var newPath = Path.GetFullPath(path); + if (File.Exists(newPath)) + File.Delete(newPath); + + var loc = newPath.LastIndexOf("\\"); + if (loc > 0) + { + var newDir = newPath.Substring(0, loc); + Directory.CreateDirectory(newDir); + } + + var msg = JsonConvert.SerializeObject(obj, Formatting.Indented); + msg = msg.Insert(1, "\"$schema\": \"https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json\",\r\n"); + + var sw = new StreamWriter(newPath); + sw.Write(msg); + sw.Flush(); + sw.Close(); + sw = null; + + //Logs.LogInformation(EIdFiles.SaveSucceed, + // $"配置{EIdFiles.SaveSucceed.GetDescription()}, Path:{path}\r\n\t\t\tCaption:{caption} 类型:{obj.GetType().FullName}."); + + return true; + } + catch (Exception ex) + { + //Logs.LogInformation(EIdFiles.SaveFailed, + // $"配置{EIdFiles.SaveFailed.GetDescription()}, Path:{path}\r\n\t\t\tCaption:{caption} 类型:{obj.GetType().FullName}.", ex.Message, ex.StackTrace); + + return false; + } + } + + #endregion +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Models/CfgRtmpReply.cs b/SHH.MjpegPlayer/Core/Models/CfgRtmpReply.cs new file mode 100644 index 0000000..c9bfe8e --- /dev/null +++ b/SHH.MjpegPlayer/Core/Models/CfgRtmpReply.cs @@ -0,0 +1,17 @@ +namespace SHH.MjpegPlayer; + +/// RTMP 配置响应类 +public class CfgRtmpReply +{ + /// 响应消息 + public string msg { get; set; } = string.Empty; + + /// 响应状态码 + public int code { get; set; } + + /// RTMP 推流地址列表 + public RtmpVo[]? rtmpVoList { get; set; } + + /// 是否成功(状态码为 200 时返回 true) + public bool IsSuccess => code == 200; +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Models/EIdSys.cs b/SHH.MjpegPlayer/Core/Models/EIdSys.cs new file mode 100644 index 0000000..f406f36 --- /dev/null +++ b/SHH.MjpegPlayer/Core/Models/EIdSys.cs @@ -0,0 +1,35 @@ +using System.ComponentModel; + +namespace SHH.MjpegPlayer +{ + public enum EIdSys + { + /// 根据PID杀掉进程成功 + [Description("根据PID杀掉进程成功")] + KillProcByIdSucceed = 1000101, + + /// 按秒统计汇总 + [Description("按秒统计汇总")] + TotalBySecond = 100701, + + /// 按分钟统计汇总 + [Description("按分钟统计汇总")] + TotalByMinute = 100702, + + /// 按小时统计汇总 + [Description("按小时统计汇总")] + TotalByHour = 100703, + + /// 查询进程名出错 + [Description("查询进程名出错")] + SearchProcNameError = 1000901, + + /// 根据PID杀掉进程出错 + [Description("根据PID杀掉进程出错")] + KillProcByIdError = 1000902, + + /// 启动进程出错 + [Description("启动进程出错")] + StartProcessError = 1000903, + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Models/JsonConfigUris.cs b/SHH.MjpegPlayer/Core/Models/JsonConfigUris.cs new file mode 100644 index 0000000..b799d16 --- /dev/null +++ b/SHH.MjpegPlayer/Core/Models/JsonConfigUris.cs @@ -0,0 +1,18 @@ +namespace SHH.MjpegPlayer +{ + public class JsonConfigUris + { + public static string DispatcherConfig; + public static string RtspRtcConfig; + public static string RtspRtcPortsConfig; + public static string MjpegConfig; + public static string CloudServerConfig; + public static string CloudServerSessionsConfig; + public static string CloudAgentConfig; + public static string CloudAITerminalConfig; + public static string VirtualCameraConfig; + public static string AIMainConfig; + public static string AIDbConfig; + public static string ToolLogConfig; + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Models/MjpegConfig.cs b/SHH.MjpegPlayer/Core/Models/MjpegConfig.cs new file mode 100644 index 0000000..8ff8a89 --- /dev/null +++ b/SHH.MjpegPlayer/Core/Models/MjpegConfig.cs @@ -0,0 +1,43 @@ +namespace SHH.MjpegPlayer; + +/// +/// Mjpeg 配置 +/// +public class MjpegConfig +{ + /// Mjpeg 服务 IP 地址 + public string SvrMjpegIp + = "0.0.0.0"; + + /// Mjpeg 服务端口开始 + public int SvrMjpegPortBegin + = 25031; + + /// Mjpeg 服务端口结束 + public int SvrMjpegPortEnd + = 25300; + + /// 帧间隔, 单位毫秒 (值为 125, 每秒 8 帧) + public int FrameInterval { get; set; } + = 125; + + /// Mjpeg Wcf 接收图片接口 + public int WcfPushImagePort + = 25030; + + /// 接收图片的服务器名称 + public string SvrNamePushImage { get; set; } + = "ImageService.svc"; + + /// 最大接收数据大小 + public int SvrPushImageMaxRecMsgSize { get; set; } + = 2000 * 1024 * 1024; + + /// Rtmp 服务地址 + public string RtmpServerDjhUri { get; set; } + = "http://172.16.41.108:8889/intellect/nvr/getRtmp"; + + /// 是否使用 Rtmp 服务 + public bool UseRtmpServer { get; set; } + = false; +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Models/RtmpVo.cs b/SHH.MjpegPlayer/Core/Models/RtmpVo.cs new file mode 100644 index 0000000..c07d771 --- /dev/null +++ b/SHH.MjpegPlayer/Core/Models/RtmpVo.cs @@ -0,0 +1,17 @@ +namespace SHH.MjpegPlayer; + +/// RTMP 推流对象类 +public class RtmpVo +{ + /// 算法代码 + public string algCode { get; set; } = string.Empty; + + /// 设备ID + public string deviceId { get; set; } = string.Empty; + + /// 设备IP地址 + public string deviceIp { get; set; } = string.Empty; + + /// RTMP 推流地址 + public string rtmp { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Models/SessionInfo.cs b/SHH.MjpegPlayer/Core/Models/SessionInfo.cs new file mode 100644 index 0000000..7e1a198 --- /dev/null +++ b/SHH.MjpegPlayer/Core/Models/SessionInfo.cs @@ -0,0 +1,100 @@ +namespace SHH.MjpegPlayer +{ + /// + /// 会话信息 + /// + public class SessionInfo + { + #region Key + + /// 流标识 + public string? Key => $"{DeviceId}#{TypeCode}"; + + #endregion + + #region DeviceId + + /// 设备类型 + public string? DeviceId { get; set; } + + #endregion + + #region TypeCode + + /// 类型编码 + public string? TypeCode { get; set; } + + #endregion + + #region ClientIp + + /// 客户端 IP + public string? ClientIp { get; set; } + + #endregion + + #region ClientPort + + /// 客户端端口 + public int ClientPort { get; set; } + + #endregion + + #region Message + + /// 消息 + public string? Message { get; set; } + + #endregion + + #region AcceptTime + + /// 接入时间 + public DateTime AcceptTime { get; set; } + + #endregion + + #region Counter + + /// 计数器 + public SumByTime? Counter { get; init; } + + #endregion + + // ======================================================= + // [新增] 专门给诊断大屏用的属性,前端可直接读取数值 + // ======================================================= + + /// 接收帧率 (源头健康度) + public int RecvFps + { + get + { + if (Counter == null || Counter.TotalSecond == null) return 0; + + // 从字典中安全获取 "接收帧数" + if (Counter.TotalSecond.TryGetValue("接收帧数", out uint val)) + { + return (int)val; + } + return 0; + } + } + + /// 播放/发送帧率 (客户端健康度) + public int PlayFps + { + get + { + if (Counter == null || Counter.TotalSecond == null) return 0; + + // 从字典中安全获取 "播放帧数" + if (Counter.TotalSecond.TryGetValue("播放帧数", out uint val)) + { + return (int)val; + } + return 0; + } + } + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Models/SumByTime.cs b/SHH.MjpegPlayer/Core/Models/SumByTime.cs new file mode 100644 index 0000000..cbdf1aa --- /dev/null +++ b/SHH.MjpegPlayer/Core/Models/SumByTime.cs @@ -0,0 +1,190 @@ +using System.Text; + +namespace SHH.MjpegPlayer +{ + /// + /// 按时间统计 + /// + public class SumByTime + { + #region Defines + + /// 最近刷新在哪一秒 + private int LastRefreshSecond = DateTime.Now.Second; + + /// 最近刷新在哪一分钟 + private int LastRefreshMinute = DateTime.Now.Minute; + + /// 最近刷新在哪一小时 + private int LastRefreshHour = DateTime.Now.Minute; + + /// 秒统计 + private Dictionary _second + = new Dictionary(); + + /// 分钟统计 + private Dictionary _minute + = new Dictionary(); + + /// 小时统计 + private Dictionary _hour + = new Dictionary(); + + /// 累计统计 + public Dictionary All { get; init; } + = new Dictionary(); + + #endregion + + #region TotalSecond + + /// 秒统计 + public Dictionary TotalSecond { get; init; } + = new Dictionary(); + + #endregion + + #region TotalMinute + + /// 分统计 + public Dictionary TotalMinute { get; init; } + = new Dictionary(); + + #endregion + + #region TotalHour + + /// 小时统计 + public Dictionary TotalHour { get; init; } + = new Dictionary(); + + #endregion + + #region Refresh + + /// + /// 刷新方法调用次数 + /// + /// + /// + /// + public void Refresh(string methodName, uint count = 1) + { + try + { + #region 加入集合 + + // 加入集合 + lock (_second) + { + if (!_second.ContainsKey(methodName)) + _second.Add(methodName, 0); + } + + // 加入集合 + lock (_minute) + { + if (!_minute.ContainsKey(methodName)) + _minute.Add(methodName, 0); + } + + lock (_hour) + { + if (!_hour.ContainsKey(methodName)) + _hour.Add(methodName, 0); + } + + // 加入集合 + lock (All) + { + if (!All.ContainsKey(methodName)) + All.Add(methodName, 0); + } + + #endregion + + #region 时间变更统计 + + // 秒刷新 + if (!LastRefreshSecond.Equals(DateTime.Now.Second)) + { + LastRefreshSecond = DateTime.Now.Second; + + var sb = new StringBuilder(); + foreach (var de in _second) + { + // 更新输出用统计信息 + if (!TotalSecond.ContainsKey(de.Key)) + TotalSecond.Add(de.Key, de.Value); + else + TotalSecond[de.Key] = de.Value; + + sb.Append($"\r\n\t{de.Key} => 执行 {de.Value} 次"); + _second[de.Key] = 0; + } + var logMsg = $"统计 => SumBySecond 统计时间:{DateTime.Now.ToString("yyyy-MM-dd HH:mm")}{sb.ToString()}"; + //Logs.LogInformation(EIdSys.TotalBySecond, logMsg); + } + + // 分钟刷新 + if (!LastRefreshMinute.Equals(DateTime.Now.Minute)) + { + LastRefreshMinute = DateTime.Now.Minute; + + var sb = new StringBuilder(); + foreach (var de in _minute) + { + // 更新输出用统计信息 + if (!TotalMinute.ContainsKey(de.Key)) + TotalMinute.Add(de.Key, de.Value); + else + TotalMinute[de.Key] = de.Value; + + sb.Append($"\r\n\t{de.Key} => 执行 {de.Value} 次, 平均每秒 {Math.Round((double)de.Value / 60, 2)} 次"); + _minute[de.Key] = 0; + } + var logMsg = $"统计 => SumByMinute 统计时间:{DateTime.Now.ToString("yyyy-MM-dd HH:mm")}{sb.ToString()}"; + //Logs.LogInformation(EIdSys.TotalByMinute, logMsg); + } + + // 小时刷新 + if (!LastRefreshHour.Equals(DateTime.Now.Hour)) + { + LastRefreshHour = DateTime.Now.Hour; + + var sb = new StringBuilder(); + foreach (var de in _hour) + { + // 更新输出用统计信息 + if (!TotalHour.ContainsKey(de.Key)) + TotalHour.Add(de.Key, de.Value); + else + TotalHour[de.Key] = de.Value; + + sb.Append($"\r\n\t{de.Key} => 执行 {de.Value} 次, 平均每秒 {Math.Round((double)de.Value / 60, 2)} 次"); + _hour[de.Key] = 0; + } + var logMsg = $"统计 => SumByHour 统计时间:{DateTime.Now.ToString("yyyy-MM-dd HH:mm")}{sb.ToString()}"; + //Logs.LogInformation(EIdSys.TotalByHour, logMsg); + } + + #endregion + + #region 数值更新 + + _second[methodName] += count; + _minute[methodName] += count; + _hour[methodName] += count; + All[methodName] += count; + + #endregion + } + catch (Exception ex) + { + //Logs.LogWarning(ex.Message); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/PrismMsg.cs b/SHH.MjpegPlayer/Core/PrismMsg.cs new file mode 100644 index 0000000..2d4a2fd --- /dev/null +++ b/SHH.MjpegPlayer/Core/PrismMsg.cs @@ -0,0 +1,68 @@ +using Prism.Events; + +namespace SHH.MjpegPlayer; + +/// Prism 消息框架 +public class PrismMsg +{ + #region Defines + + public IEventAggregator _ea; + + private static PrismMsg? _instance = null; + + #endregion + + #region Constructor + + /// 构造函数 + private PrismMsg() + { + _ea = new EventAggregator(); + } + + #endregion + + #region Instance + + /// 获取实例信息 + public static PrismMsg Instance + { + get + { + if (_instance == null) + _instance = new PrismMsg(); + + return _instance; + } + } + + #endregion + + #region Publish + + /// 发送消息 + public static void Publish(T msg) + { + if (Instance == null) + return; + + dynamic? data = msg; + Instance._ea.GetEvent>().Publish(data); + } + + #endregion + + #region Subscribe + + /// 订阅消息 + public static void Subscribe(Action method) + { + if (Instance == null || Instance._ea == null) + return; + + Instance._ea.GetEvent>().Subscribe(method); + } + + #endregion +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Core/Watchs/MemoryWatchdog.cs b/SHH.MjpegPlayer/Core/Watchs/MemoryWatchdog.cs new file mode 100644 index 0000000..2734ab0 --- /dev/null +++ b/SHH.MjpegPlayer/Core/Watchs/MemoryWatchdog.cs @@ -0,0 +1,101 @@ +using Ayay.SerilogLogs; +using Serilog; +using System.Diagnostics; +using System.Timers; + +namespace SHH.MjpegPlayer; + +/// +/// 内存监控 +/// +public static class MemoryWatchdog +{ + private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core); + + private static System.Timers.Timer? _timer; + private static long _thresholdBytes; + + private static ILogger _logger => Log.Logger; + + /// + /// 启动内存监控 + /// + /// 检查间隔(秒),默认60秒 + /// 内存阈值(MB),超过此值自动退出,默认800MB + public static void Start(int intervalSeconds = 60, int limitMB = 800) + { + // 1. 参数安全检查 + if (intervalSeconds < 1) intervalSeconds = 1; // 至少 1 秒 + if (limitMB < 100) limitMB = 100; // 至少100MB,防止误杀 + + // 2. 转换单位 + // MB -> Bytes + _thresholdBytes = (long)limitMB * 1024 * 1024; + + // 秒 -> 毫秒 + double intervalMs = intervalSeconds * 1000.0; + + // 3. 初始化定时器 + Stop(); // 防止重复启动 + _timer = new System.Timers.Timer(intervalMs); + _timer.Elapsed += CheckMemoryUsage; + _timer.AutoReset = true; // 循环执行 + _timer.Start(); + + // 可选:记录启动日志 + if (_logger != null) + { + _sysLog.Warning($"[系统] 内存看门狗已启动。每 {intervalSeconds} 秒检查一次,阈值: {limitMB} MB."); + } + } + + private static void CheckMemoryUsage(object sender, ElapsedEventArgs e) + { + try + { + Process currentProc = Process.GetCurrentProcess(); + + // 【重要】刷新快照 + currentProc.Refresh(); + + long currentUsage = currentProc.WorkingSet64; + + if (currentUsage > _thresholdBytes) + { + double currentMB = currentUsage / 1024.0 / 1024.0; + double limitMB = _thresholdBytes / 1024.0 / 1024.0; + + if (_logger != null) + { + _sysLog.Warning($"[严重] 内存占用 ({currentMB:F2} MB) 超过阈值 ({limitMB} MB),程序即将自杀重启或退出."); + } + + // 等待日志输出 + for (var i = 0; i < 10; i++) + { + System.Threading.Thread.Sleep(100); + } + + // 强制退出 + Environment.Exit(0); + } + } + catch (Exception ex) + { + if (_logger != null) + { + _sysLog.Warning($"[严重] 内存检查出错."); + } + } + } + + public static void Stop() + { + if (_timer != null) + { + _timer.Stop(); + _timer.Dispose(); + _timer = null; + } + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/GrpcImpls/Core/ConcurrentHashSet.cs b/SHH.MjpegPlayer/GrpcImpls/Core/ConcurrentHashSet.cs new file mode 100644 index 0000000..0fc1ddd --- /dev/null +++ b/SHH.MjpegPlayer/GrpcImpls/Core/ConcurrentHashSet.cs @@ -0,0 +1,22 @@ +using System.Collections.Concurrent; + +namespace SHH.MjpegPlayer +{ + /// + /// 辅助类:线程安全集合 + /// + public class ConcurrentHashSet : IEnumerable + { + private readonly ConcurrentDictionary _dict = new ConcurrentDictionary(); + + public void Add(T item) => _dict.TryAdd(item, 0); + + public void Remove(T item) => _dict.TryRemove(item, out _); + + public bool IsEmpty => _dict.IsEmpty; + + public IEnumerator GetEnumerator() => _dict.Keys.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceConfigHandler.cs b/SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceConfigHandler.cs new file mode 100644 index 0000000..6a5ed0a --- /dev/null +++ b/SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceConfigHandler.cs @@ -0,0 +1,106 @@ +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace SHH.MjpegPlayer +{ + /// + /// 设备配置同步处理器 (原 ConfigSyncManager 瘦身版) + /// 职责:仅负责确保远程分析节点(Instance)的摄像头配置与本地数据库一致。 + /// 逻辑:通过 5 秒初始化冷却期避开抖动,并利用配置快照对比实现增量同步。 + /// + public class DeviceConfigHandler + { + #region 单例与核心存储字段 + + /// + /// 获取配置处理器的全局单例实例 + /// + public static DeviceConfigHandler Instance { get; } = new DeviceConfigHandler(); + + /// + /// 活跃服务实例 ID 集合 (InstanceId) + /// 用于记录当前所有已建立 gRpc 长连接的远程节点 + /// + private readonly ConcurrentHashSet _activeServiceIds = new ConcurrentHashSet(); + + /// + /// 配置快照缓存:用于防止重复下发相同的配置 + /// Key 格式: "InstanceId_CameraId" + /// Value: 该摄像头配置的 JSON 字符串快照 + /// + private readonly ConcurrentDictionary _lastSentConfigCache = new ConcurrentDictionary(); + + /// + /// 后台监控任务的任务取消令牌源 + /// + private CancellationTokenSource _cts; + + /// + /// 初始化完成时间戳:用于 5 秒冷却期判定 + /// 防止在服务刚启动或节点刚连接时,由于数据库加载延迟导致误判设备被移除 + /// + private DateTime _initCompleteTime = DateTime.MaxValue; + + #endregion + + #region 构造函数与初始化 + + /// + /// 私有构造函数:订阅消息总线并启动监控任务 + /// + private DeviceConfigHandler() + { + // 订阅总线:仅关注节点注册事件,以此作为触发初始化全量同步的开关 + MessageBus.Instance.OnServerRegistered += async (payload) => + { + await HandleServiceOnlineAsync(payload.InstanceId); + }; + + // 启动后台轮询监控任务 (检测 Add/Update/Remove) + StartMonitorTask(); + } + + #endregion + + #region 核心业务处理 (节点上线) + + /// + /// 处理新节点上线:执行全量同步 + /// + /// 远程服务实例唯一标识 + private async Task HandleServiceOnlineAsync(string instanceId) + { + // 1. 将新实例记录到活跃列表 + _activeServiceIds.Add(instanceId); + + // 2. 预留 1 秒等待期,确保 gRpc 双向通道完全稳定 + await Task.Delay(1000); + + //// 3. 从数据库拍摄当前所有摄像头的快照 + //var snapshot = CSdkStatics.DbCameras.ToList(); + + //// 4. 对新节点执行全量下发 + //foreach (var cam in snapshot) + //{ + // await SendSyncCommandAsync(instanceId, cam); + //} + + // 5. 更新冷却期起始点 + _initCompleteTime = DateTime.Now; + Debug.WriteLine($"[ConfigHandler] 节点 {instanceId} 初始化全量同步已完成。"); + } + + #endregion + + #region 后台监控任务 (增量同步) + + /// + /// 启动后台增量监控任务 + /// + private void StartMonitorTask() + { + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceStatusHandler.cs b/SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceStatusHandler.cs new file mode 100644 index 0000000..dd78e2d --- /dev/null +++ b/SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceStatusHandler.cs @@ -0,0 +1,48 @@ +using SHH.Contracts; + +namespace SHH.MjpegPlayer +{ + /// + /// 设备状态处理器 + /// 职责:监听消息总线发出的状态主题事件,负责将远程节点上报的相机在线/离线状态实时同步至本地管理中心。 + /// 架构说明:此类实现了业务逻辑的彻底解耦,不涉及 gRpc 通讯细节,也不涉及复杂的配置下发逻辑。 + /// + public class DeviceStatusHandler + { + #region 单例模式 + + /// + /// 获取设备状态处理器的全局单例实例。 + /// 由 GrpcServerManager 在系统启动时显式调用以完成初始化。 + /// + public static DeviceStatusHandler Instance { get; } = new DeviceStatusHandler(); + + /// + /// 私有构造函数:在此处完成对消息总线事件的订阅。 + /// + private DeviceStatusHandler() + { + // 订阅 MessageBus 的状态报告主题,当总线收到状态更新包时自动触发 SyncToLocal + MessageBus.Instance.OnDeviceStatusReport += SyncToLocal; + } + + #endregion + + #region 核心业务逻辑 + + /// + /// 执行状态同步:将收到的 Payload 数据精确映射回本地 SDK 管理的摄像头集合中。 + /// + /// 包含 CameraId 和在线状态的业务载荷列表 + private void SyncToLocal(List items) + { + // 1. 基础校验:若无数据则不执行后续逻辑 + if (items == null || items.Count == 0) return; + + // 2. 性能优化:将上报列表转换为字典,利用哈希查找提升大数据量下的匹配效率 (Key: CameraId 字符串) + var stateMap = items.ToDictionary(k => k.CameraId, v => v); + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/GrpcImpls/Handlers/GatewayService.cs b/SHH.MjpegPlayer/GrpcImpls/Handlers/GatewayService.cs new file mode 100644 index 0000000..c1a2804 --- /dev/null +++ b/SHH.MjpegPlayer/GrpcImpls/Handlers/GatewayService.cs @@ -0,0 +1,156 @@ +using Grpc.Core; +using SHH.Contracts; +using SHH.Contracts.Grpc; + +namespace SHH.MjpegPlayer +{ + /// + /// gRpc 网关服务 + /// 职责:作为服务端通讯入口,负责接收客户端(分析节点)的所有 gRpc 请求,将其转译为内部业务载荷, + /// 并通过消息总线 MessageBus 分发至对应的业务处理器。 + /// + public class GatewayService : GatewayProvider.GatewayProviderBase + { + #region 1. 逻辑身份注册 (Unary 调用) + + /// + /// 处理分析节点的注册请求 + /// + /// 包含节点实例 ID 和服务器 IP 的请求对象 + /// gRpc 调用上下文 + /// 操作成功响应 + public override Task RegisterInstance(RegisterRequest request, ServerCallContext context) + { + // 1. 将 Protobuf 契约对象转换为业务层的 RegisterPayload (DTO) + // 职责:将外部传输格式映射为内部业务模型,实现协议与业务逻辑的解耦 + var payload = new RegisterPayload + { + // 身份标识映射 + ProcessId = request.ProcessId, + InvokeProcId = request.InvokeProcessId, + InstanceId = request.InstanceId, + Version = request.Version, + + // 网络诊断信息映射 + ServerIp = request.ServerIp, + WebApiPort = request.WebapiPort, + GrpcPort = request.GrpcPort, + + // 运行时状态映射 + // 注意:将 int64 类型的 Ticks 转换为 C# 的 DateTime 对象 + StartTime = new DateTime(request.StartTimeTicks), + Description = request.Description + }; + + // 2. 将注册载荷抛给总线,触发如 DeviceConfigHandler 的配置初始化逻辑 + // 职责:通过中介者模式分发事件,网关层不需要知道谁在处理这些数据 + MessageBus.Instance.RaiseServerRegistered(payload); + + return Task.FromResult(new GenericResponse { Success = true }); + } + + #endregion + + #region 2. 指令下发长连接 (Server Streaming) + + /// + /// 建立并维持一个从服务器向客户端单向推送指令的长连接通道 + /// + /// 连接请求(包含 InstanceId) + /// 响应流,用于后续异步推送指令 + /// gRpc 调用上下文 + /// 异步任务 + public override async Task OpenCommandChannel(CommandStreamRequest request, IServerStreamWriter responseStream, ServerCallContext context) + { + // 1. 物理流登记:将此响应流句柄存入 GrpcSessionManager,以便 MessageBus 随时调用 + GrpcSessionManager.Instance.RegisterSession(request.InstanceId, responseStream); + + try + { + // 2. 挂起连接:利用 Task.Delay(-1) 配合取消令牌无限期挂起连接,直到客户端断开 + await Task.Delay(-1, context.CancellationToken); + } + catch (OperationCanceledException) + { + // 客户端主动取消连接属于正常预期,无需抛出异常 + } + finally + { + // 3. 物理流清理:当连接断开时,必须从会话管理器中移除,防止下发指令时产生死连接 + GrpcSessionManager.Instance.RemoveSession(request.InstanceId); + } + } + + #endregion + + #region 3. 设备状态批量上报 (Unary 调用) + + /// + /// 接收来自分析节点的相机在线/离线状态批量上报 + /// + /// 包含多个设备状态项的请求对象 + /// gRpc 调用上下文 + /// 操作成功响应 + public override Task ReportStatusBatch(StatusBatchRequest request, ServerCallContext context) + { + if (request.Items == null || !request.Items.Any()) + return Task.FromResult(new GenericResponse { Success = true }); + + // 1. 数据映射:将 Proto 集合转换为业务层的 StatusEventPayload 列表 + var payloads = request.Items.Select(item => new StatusEventPayload + { + CameraId = item.CameraId, + IsOnline = item.IsOnline, + Reason = item.Reason, + Timestamp = request.Timestamp + }).ToList(); + + // 2. 路由分发:通过总线发布状态主题,驱动 DeviceStatusHandler 执行同步 + MessageBus.Instance.RaiseDeviceStatusReport(payloads); + + return Task.FromResult(new GenericResponse { Success = true }); + } + + #endregion + + #region 4. 视频流传输接收 (Client Streaming) + + /// + /// 接收分析节点持续推送的视频帧数据流 + /// + /// 客户端异步流读取器 + /// gRpc 调用上下文 + /// 流关闭后的最终响应 + public override async Task UploadVideoStream(IAsyncStreamReader requestStream, ServerCallContext context) + { + // 1. 持续读取客户端推送的每一帧数据,直到流关闭或被取消 + while (await requestStream.MoveNext(context.CancellationToken)) + { + var frame = requestStream.Current; + + // 2. 将 Protobuf 帧数据转换为业务视频载荷 VideoPayload + // 注意:ByteString 需要显式调用 ToByteArray 转换 + var videoPayload = new VideoPayload + { + CameraId = frame.CameraId, + CaptureTimestamp = frame.CaptureTimestamp, + OriginalWidth = frame.OriginalWidth, + OriginalHeight = frame.OriginalHeight, + OriginalImageBytes = frame.OriginalImageBytes.ToByteArray(), + TargetImageBytes = frame.TargetImageBytes.ToByteArray(), + TargetWidth = frame.TargetWidth, + TargetHeight = frame.TargetHeight, + SubscriberIds = frame.SubscriberIds.ToList(), + HasOriginalImage = true + }; + + // 3. 导流:将图像数据直接投递给图像分发控制器进行 UI 渲染或二次处理 + ImageMonitorController.Instance.ReceivePayload(videoPayload); + } + + return new GenericResponse { Success = true, Message = "Video stream ended" }; + } + + #endregion + } +} diff --git a/SHH.MjpegPlayer/GrpcImpls/Handlers/GrpcSessionManager.cs b/SHH.MjpegPlayer/GrpcImpls/Handlers/GrpcSessionManager.cs new file mode 100644 index 0000000..6d2353b --- /dev/null +++ b/SHH.MjpegPlayer/GrpcImpls/Handlers/GrpcSessionManager.cs @@ -0,0 +1,108 @@ +using Grpc.Core; +using SHH.Contracts.Grpc; +using System.Collections.Concurrent; + +namespace SHH.MjpegPlayer +{ + /// + /// gRpc 会话管理器 + /// 职责:专门负责维护、检索和清理所有远程客户端(分析节点)的 gRpc 指令下发物理通道 (Stream)。 + /// 它是连接“业务逻辑”与“物理传输”的桥梁,确保指令能准确投递到对应的连接流中。 + /// + public class GrpcSessionManager + { + #region 单例模式 + + /// + /// 获取会话管理器的全局单例实例。 + /// + public static GrpcSessionManager Instance { get; } = new GrpcSessionManager(); + + /// + /// 私有构造函数,防止外部实例化。 + /// + private GrpcSessionManager() { } + + #endregion + + #region 内部存储 + + /// + /// 物理流存储字典 + /// Key: 远程服务实例唯一 ID (InstanceId) + /// Value: gRpc 双向流或服务端推送流的写入器句柄 (IServerStreamWriter) + /// 使用 ConcurrentDictionary 确保在多客户端并发连接/断开时的线程安全性。 + /// + private readonly ConcurrentDictionary> _sessionStreams + = new ConcurrentDictionary>(); + + #endregion + + #region 公共管理接口 + + /// + /// 注册/更新物理物理通道。 + /// 当客户端调用 OpenCommandChannel 并成功建立 Server Streaming 连接时,由 GatewayService 调用此方法。 + /// + /// 客户端实例唯一标识 + /// 该客户端对应的 gRpc 响应流句柄 + public void RegisterSession(string instanceId, IServerStreamWriter responseStream) + { + // 1. 参数校验:无效 ID 不予处理 + if (string.IsNullOrEmpty(instanceId)) return; + + // 2. 登记或覆盖物理流: + // 如果客户端异常断开后迅速重连,此处会覆盖旧的流句柄,确保指令始终通过最新的管道下发。 + _sessionStreams[instanceId] = responseStream; + + // 3. 记录日志:便于运维监控连接状态 + Console.WriteLine($"[Session] 物理通道就绪通知 -> 节点 ID: {instanceId}, 当前在线总数: {_sessionStreams.Count}"); + } + + /// + /// 移除物理通道。 + /// 当 gRpc 连接由于网络波动、客户端崩溃或主动关闭而断开时,由 GatewayService 的 finally 块调用。 + /// + /// 要注销的客户端实例 ID + public void RemoveSession(string instanceId) + { + // 1. 参数校验 + if (string.IsNullOrEmpty(instanceId)) return; + + // 2. 安全移除:若 ID 存在则移除并释放相关内部引用 + if (_sessionStreams.TryRemove(instanceId, out _)) + { + Console.WriteLine($"[Session] 物理通道移除通知 -> 节点 ID: {instanceId}, 剩余在线总数: {_sessionStreams.Count}"); + } + } + + /// + /// 检索目标节点的物理流句柄。 + /// 供 MessageBus 使用,它是指令下发前定位物理路径的关键步骤。 + /// + /// 目标节点的唯一 ID + /// 返回对应的 IServerStreamWriter 实例;若节点不在线则返回 null + public IServerStreamWriter GetSession(string instanceId) + { + // 1. 参数校验 + if (string.IsNullOrEmpty(instanceId)) return null; + + // 2. 尝试从缓存字典中获取流句柄 + _sessionStreams.TryGetValue(instanceId, out var stream); + + return stream; + } + + /// + /// 检查指定节点是否处于物理连接状态。 + /// + /// 实例 ID + /// True 表示物理通道已建立 + public bool IsSessionActive(string instanceId) + { + return !string.IsNullOrEmpty(instanceId) && _sessionStreams.ContainsKey(instanceId); + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/GrpcImpls/Handlers/MessageBus.cs b/SHH.MjpegPlayer/GrpcImpls/Handlers/MessageBus.cs new file mode 100644 index 0000000..dd6c776 --- /dev/null +++ b/SHH.MjpegPlayer/GrpcImpls/Handlers/MessageBus.cs @@ -0,0 +1,137 @@ +using SHH.Contracts; +using SHH.Contracts.Grpc; +using System.Diagnostics; + +namespace SHH.MjpegPlayer +{ + /// + /// 消息总线中心 (纯 gRpc 架构) + /// 职责:解耦 gRpc 接收端与业务处理层,提供基于主题(Topic)的事件发布与统一的指令下发路由。 + /// + public class MessageBus : IDisposable + { + #region 单例模式 + + /// + /// 消息总线全局唯一实例 + /// + public static MessageBus Instance { get; } = new MessageBus(); + + /// + /// 私有构造函数 + /// + private MessageBus() { } + + #endregion + + #region 业务事件订阅主题 (Topics) + + /// + /// 1. 注册主题:当远程分析节点成功建立逻辑连接时触发。 + /// 订阅者通常为 DeviceConfigHandler,用于启动初始化配置同步。 + /// + public event Action? OnServerRegistered; + + /// + /// 2. 状态主题:当收到远程节点批量上报的设备在线/离线状态时触发。 + /// 订阅者通常为 DeviceStatusHandler,用于更新 UI 状态。 + /// + public event Action>? OnDeviceStatusReport; + + #endregion + + #region 事件发布接口 (供 GatewayService 接收端调用) + + /// + /// 发布节点注册事件:将 gRpc 接收到的原始注册请求推送到业务层 + /// + /// 注册载荷信息 + public void RaiseServerRegistered(RegisterPayload p) + { + if (p == null) return; + + // 调试日志:跟踪节点上线流程 + Debug.WriteLine($"[Bus] 发布注册事件: 节点ID = {p.InstanceId}"); + + // 执行所有已订阅该主题的业务逻辑 + OnServerRegistered?.Invoke(p); + } + + /// + /// 发布状态报告事件:将 gRpc 接收到的设备状态批量推送到业务层 + /// + /// 设备状态变更列表 + public void RaiseDeviceStatusReport(List items) + { + if (items == null || items.Count == 0) return; + + // 执行所有已订阅状态同步的业务逻辑 + OnDeviceStatusReport?.Invoke(items); + } + + #endregion + + #region 指令下发接口 (供各 Handler 业务层调用) + + /// + /// 统一指令下发路由:自动定位目标节点的物理 gRpc 流并推送指令载荷 + /// + /// 目标分析节点的唯一识别码 + /// 要发送的业务指令负载 + /// 异步任务 + public async Task SendInternalAsync(string instanceId, CommandPayload payload) + { + // 1. 获取由 GrpcSessionManager 维护的物理长连接流 + var stream = GrpcSessionManager.Instance.GetSession(instanceId); + + // 2. 健壮性检查:若连接不存在则终止下发 + if (stream == null) + { + Debug.WriteLine($"[Bus Warning] 指令下发终止:节点 {instanceId} 尚未建立物理连接。"); + return; + } + + try + { + // 3. 契约转换:将业务层 CommandPayload 转换为 gRpc 生成的 Protobuf 契约对象 + var protoMsg = new CommandPayloadProto + { + Protocol = payload.Protocol, + CmdCode = payload.CmdCode, + JsonParams = payload.JsonParams, + RequestId = payload.RequestId, + TimestampTicks = payload.Timestamp.Ticks + }; + + // 4. 执行异步推送 + await stream.WriteAsync(protoMsg); + + Debug.WriteLine($"[Bus] 指令推送成功 -> 目标: {instanceId}, 指令码: {payload.CmdCode}"); + } + catch (Exception ex) + { + // 5. 异常处理:若推送失败,通常意味着网络链路已断开 + Debug.WriteLine($"[Bus Error] 推送异常: {ex.Message},正在执行物理连接清理..."); + + // 立即移除失效会话,防止后续指令继续掉入“黑洞” + GrpcSessionManager.Instance.RemoveSession(instanceId); + } + } + + #endregion + + #region 资源释放 + + /// + /// 释放总线资源 + /// + public void Dispose() + { + // 清理所有事件订阅,防止内存泄漏 + OnServerRegistered = null; + OnDeviceStatusReport = null; + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/GrpcImpls/ImageProcs/ImageMonitorController.cs b/SHH.MjpegPlayer/GrpcImpls/ImageProcs/ImageMonitorController.cs new file mode 100644 index 0000000..c989ac3 --- /dev/null +++ b/SHH.MjpegPlayer/GrpcImpls/ImageProcs/ImageMonitorController.cs @@ -0,0 +1,37 @@ +using SHH.Contracts; +using System.Diagnostics; + +namespace SHH.MjpegPlayer +{ + /// + /// AI 视频流监控控制器 + /// 职责:接收 gRpc 转换后的 Payload -> 业务转换 -> 分发 UI/AI + /// + public class ImageMonitorController + { + public static ImageMonitorController Instance { get; } = new ImageMonitorController(); + + private ImageMonitorController() { } + + /// + /// 统一接收入口:由 GatewayProviderImpl.UploadVideoStream 调用 + /// + public void ReceivePayload(VideoPayload payload) + { + if (payload == null) return; + + // 1. 过滤 2 秒外的过期数据 + if ((DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - payload.CaptureTimestamp) > 2000) + return; + + try + { + ImagePayloadConverter.ToXWcfMsg(payload); + } + catch (Exception ex) + { + Debug.WriteLine($"[Controller Error] {ex.Message}"); + } + } + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/GrpcImpls/ImageProcs/ImagePayloadConverter.cs b/SHH.MjpegPlayer/GrpcImpls/ImageProcs/ImagePayloadConverter.cs new file mode 100644 index 0000000..60099f1 --- /dev/null +++ b/SHH.MjpegPlayer/GrpcImpls/ImageProcs/ImagePayloadConverter.cs @@ -0,0 +1,78 @@ +using Core.WcfProtocol; +using SHH.Contracts; + +namespace SHH.MjpegPlayer +{ + /// + /// 图像载荷转换器 (原 PayloadConverter) + /// 职责:抹平传输契约与业务契约之间的差异。 + /// + public static class ImagePayloadConverter + { + /// + /// 将视频负载转换为 XWcf 协议并分发至会话池 + /// + /// VideoPayload 纯净版契约对象 + public static void ToXWcfMsg(VideoPayload payload) + { + if (payload == null) return; + + try + { + // 1. 自动选择图像源逻辑 + // Optimized: 优先使用 TargetImage,若为空则退而求其次使用 OriginalImage + bool isOriginal = false; + byte[]? activeBytes; + activeBytes = payload.TargetImageBytes; + if (payload.TargetImageBytes == null || payload.TargetImageBytes.Length == 0) + { + isOriginal = true; + activeBytes = payload.OriginalImageBytes; + } + + // 如果两者都为空,则不进行分发 + if (activeBytes == null || activeBytes.Length == 0) return; + + // 同理处理宽高:Target 为 0 则使用 Original + int activeWidth = !isOriginal ? payload.TargetWidth : payload.OriginalWidth; + int activeHeight = !isOriginal ? payload.TargetHeight : payload.OriginalHeight; + + // 2. 构造分发所需的 UploadImageRequest + // Modified: [原因] 适配最新的 VideoPayload 契约字段 + var req = new UploadImageRequest + { + // 解析 CameraId。由于旧 req 是 Int64 Id,若 CameraId 是数字字符串则解析,否则处理 Hash + Id = long.TryParse(payload.CameraId, out long id) ? id : 0, + Name = payload.CameraId, // 将原始 CameraId 存入 Name 字段保留引用 + + // 默认类型处理 (可根据 Diagnostics 中的信息动态调整) + Type = "0", + + Order = (ulong)payload.CaptureTimestamp, // 使用采集时间戳作为序号 + Time = UnixMillisecondsToDateTime(payload.CaptureTimestamp), + + ImageBytes = activeBytes, // 零拷贝引用 + ImageWidth = activeWidth, + ImageHeight = activeHeight + }; + + // 3. 执行核心分发逻辑 + // 此处调用你之前提供的 O(1) 检索分发方法,确保画面最终流向 DoImageProc + MjpegStatics.Sessions.ProcUploadImageRequest(req); + } + catch (Exception ex) + { + // 统一使用项目规范的 _sysLog + //_sysLog.Error(ex, "VideoPayload 转换分发失败. CameraId: {CameraId}", payload.CameraId); + } + } + + /// + /// 辅助方法:Unix 毫秒时间戳转 DateTime + /// + private static DateTime UnixMillisecondsToDateTime(long timestamp) + { + return DateTimeOffset.FromUnixTimeMilliseconds(timestamp).LocalDateTime; + } + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/GrpcServerManager.cs b/SHH.MjpegPlayer/GrpcServerManager.cs new file mode 100644 index 0000000..0686ff4 --- /dev/null +++ b/SHH.MjpegPlayer/GrpcServerManager.cs @@ -0,0 +1,62 @@ +using Grpc.Core; +using SHH.Contracts.Grpc; + +namespace SHH.MjpegPlayer +{ + /// + /// gRpc 服务宿主管理器 + /// 职责:初始化业务处理器、配置并启动 gRpc 监听服务。 + /// + public static class GrpcServerManager + { + private static Server? _server; + + /// + /// 启动 gRpc 服务并初始化业务 Handler + /// + public static void Start() + { + try + { + // 1. 显式初始化业务处理器 (确保单例构造函数执行,完成事件订阅) + // 必须在服务启动前完成,否则可能丢失首批注册事件 + _ = DeviceConfigHandler.Instance; + _ = DeviceStatusHandler.Instance; + Console.WriteLine("[System] 业务处理器 (Config/Status) 已初始化。"); + + // 2. 配置 gRpc 服务器 + _server = new Server + { + // 绑定重构后的 GatewayService + Services = { GatewayProvider.BindService(new GatewayService()) }, + // 监听 9002 端口 + Ports = { new ServerPort("[::]", 9002, ServerCredentials.Insecure) } + }; + + // 3. 开启服务 + _server.Start(); + + Console.WriteLine("======================================"); + Console.WriteLine("gRpc 服务端启动成功!端口: 9002"); + Console.WriteLine("======================================"); + } + catch (Exception ex) + { + Console.WriteLine($"[Critical] gRpc 启动失败: {ex.Message}"); + // 此处建议记录到本地错误日志文件 + } + } + + /// + /// 停止服务并释放资源 + /// + public static void Stop() + { + if (_server != null) + { + _server.ShutdownAsync().Wait(); + Console.WriteLine("[System] gRpc 服务已停止。"); + } + } + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/MJpegPlayer.Solution.sln b/SHH.MjpegPlayer/MJpegPlayer.Solution.sln new file mode 100644 index 0000000..a208ea6 --- /dev/null +++ b/SHH.MjpegPlayer/MJpegPlayer.Solution.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36623.8 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.Contracts.Grpc", "..\SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj", "{5CBDD688-1CD0-4E63-81C5-8E18750D891A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ayay.SerilogLogs", "..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj", "{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.MjpegPlayer", "SHH.MjpegPlayer.csproj", "{13828F44-AC67-4DFE-A3BC-3F1CD153A59A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Release|Any CPU.Build.0 = Release|Any CPU + {0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Release|Any CPU.Build.0 = Release|Any CPU + {13828F44-AC67-4DFE-A3BC-3F1CD153A59A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13828F44-AC67-4DFE-A3BC-3F1CD153A59A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13828F44-AC67-4DFE-A3BC-3F1CD153A59A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13828F44-AC67-4DFE-A3BC-3F1CD153A59A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {64321063-16F8-41E4-9595-E85C32FE4FDC} + EndGlobalSection +EndGlobal diff --git a/SHH.MjpegPlayer/MjpegStatics.cs b/SHH.MjpegPlayer/MjpegStatics.cs new file mode 100644 index 0000000..724ef34 --- /dev/null +++ b/SHH.MjpegPlayer/MjpegStatics.cs @@ -0,0 +1,26 @@ +namespace SHH.MjpegPlayer +{ + /// + /// 静态参数集合 + /// + public class MjpegStatics + { + /// + /// 配置项 + /// + public static MjpegConfig Cfg { get; set; } + = new MjpegConfig(); + + /// + /// 会话集合 + /// + public static MjpegSessions Sessions { get; private set; } + = new MjpegSessions(); + + /// + /// 图片通道集合 + /// + public static ImageChannels ImageChannels { get; private set; } + = new ImageChannels(); + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Program.cs b/SHH.MjpegPlayer/Program.cs new file mode 100644 index 0000000..a348c98 --- /dev/null +++ b/SHH.MjpegPlayer/Program.cs @@ -0,0 +1,107 @@ +using Ayay.SerilogLogs; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Serilog; + +namespace SHH.MjpegPlayer +{ + internal class Program + { + private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core); + + static void Main(string[] args) + { + _sysLog.Information("MjpegPlayer 正在初始化..."); + + var builder = WebApplication.CreateBuilder(args); + + // 1. 注册 gRpc 服务 + builder.Services.AddGrpc(options => { + options.MaxReceiveMessageSize = 10 * 1024 * 1024; // 针对工业视频流,建议放宽至 10MB + }); + + // 2. 注册业务单例(如你之前的 Handler) + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + var app = builder.Build(); + + // 3. 映射服务路(将逻辑与端口绑定) + app.MapGrpcService(); + + new Thread(StartServer).Start(); + + GrpcServerManager.Start(); + + // 4. 启动监听(代替 Console.ReadLine) + // 建议端口与 CameraService 配置的 9002 保持一致 + _sysLog.Information("MjpegPlayer gRPC 服务启动于端口 9002"); + app.Run("http://0.0.0.0:9002"); + } + + + #region StartServer + + /// + /// 开启服务监听 + /// + static void StartServer() + { + try + { + // 加载配置文件 + var cfg = Bootstrapper.LoadConfig(); + + // 检查 IP 与端口 + Bootstrapper.ValidateEnvironment(); + + // 开启 Wcf 服务 + StartWcfServer(); + _sysLog.Information("WCF 推流接口已就绪, 端口: {Port}", cfg.WcfPushImagePort); + + // 会话开启 + // [修复] 端口循环逻辑:同样改为 <= 以匹配检测逻辑 + for (var i = cfg.SvrMjpegPortBegin; i <= cfg.SvrMjpegPortEnd; i++) + { + MjpegServer.Start(i); + } + _sysLog.Information("MJPEG 服务池已开启: {Begin} -> {End}", cfg.SvrMjpegPortBegin, cfg.SvrMjpegPortEnd); + + // 开启 RTMP 服务 + RtmpPushServer.Instance.Start(); + } + catch (Exception ex) + { + //Logs.LogCritical(ex.Message, ex.StackTrace); + // 退出应用 + Bootstrapper.ExitApp("应用程序崩溃."); + } + } + + #endregion + + + #region StartWcfServer + + /// + /// 开启 Wcf 服务 + /// + private static void StartWcfServer() + { + try + { + var cfg = MjpegStatics.Cfg; + Bootstrapper.StartWcfEngine(cfg); + } + catch (Exception ex) + { + _sysLog.Fatal(ex, "应用程序崩溃."); + + // 退出应用 + Bootstrapper.ExitApp("应用程序崩溃."); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Protocols/Base2Reply.cs b/SHH.MjpegPlayer/Protocols/Base2Reply.cs new file mode 100644 index 0000000..0f2057c --- /dev/null +++ b/SHH.MjpegPlayer/Protocols/Base2Reply.cs @@ -0,0 +1,378 @@ +using System.Runtime.Serialization; + +namespace Core.Protocol +{ + /// + /// 基础响应分页 + /// + public class BaseReplyPagination + { + /// + /// 当前页 + /// + [DataMember] + public int Current_Page { get; set; } + = 1; + + /// + /// 每页数量 + /// + [DataMember] + public int Page_Size { get; set; } + = 1000; + + /// + /// 总记录数 + /// + [DataMember] + public int Total { get; set; } + = 0; + } + + #region BaseReply + + /// + /// 基础响应 + /// + [DataContract] + public class BaseReply + { + /// + /// 是否成功 + /// + [DataMember] + public Guid ExecGuid { get; set; } + + /// + /// 执行码 + /// + [DataMember] + public int Code { get; set; } + + /// + /// 是否成功 + /// + [DataMember] + public bool Success { get; set; } + + /// + /// 执行消息 + /// + [DataMember] + public string Msg { get; set; } + = string.Empty; + + /// + /// 数据API + /// + [DataMember] + public string? DataApi { get; set; } + + /// + /// 数据主体 + /// + [DataMember] + public object? DataTable { get; set; } + + /// + /// 数据对象 + /// + [DataMember] + public object? DataObject { get; set; } + + /// + /// 列信息 + /// + [DataMember] + public List? Columns { get; set; } + = new List(); + + /// + /// 分页信息 + /// + [DataMember] + public BaseReplyPagination Pagination { get; set; } + = new BaseReplyPagination(); + + #region Create + + /// + /// 创建基础响应对象 + /// + /// + /// + public static BaseReply Create(string msg) + { + var reply = new BaseReply(); + reply.Msg = msg; + + reply.ReplySuccess(); + return reply; + } + + #endregion + + #region Create + + /// + /// 创建基础响应对象 + /// + /// + public static BaseReply Create(List data, List? columns = null) + { + var reply = new BaseReply(); + reply.DataTable = data; + reply.Columns = columns; + + if (data != null) + reply.Pagination.Total = data.Count; + + reply.ReplySuccess(); + return reply; + } + + #endregion + + #region Create + + /// + /// 创建基础响应对象 + /// + /// + public static BaseReply Create(List data, List? columns = null) + { + var reply = new BaseReply(); + reply.DataTable = data; + reply.Columns = columns; + + if (data != null) + reply.Pagination.Total = data.Count; + + reply.ReplySuccess(); + return reply; + } + + #endregion + + #region CreateFalt + + /// + /// 创建基础响应对象 + /// + /// + /// + public static BaseReply CreateFalt(string msg = "失败") + { + var reply = new BaseReply(); + reply.Success = false; + reply.Code = -1; + reply.Msg = msg; + return reply; + } + + #endregion + + #region ReplySuccess + + /// + /// 成功 + /// + public void ReplySuccess() + { + Success = true; + Code = 200; + + if (string.IsNullOrEmpty(Msg)) + Msg = "成功"; + } + + #endregion + + #region ReplyFalt + + /// + /// 失败 + /// + public void ReplyFalt(string msg = "失败", int code = -1) + { + Success = false; + Code = code; + Msg = msg; + } + + #endregion + } + + #endregion + + /// + /// 基础响应 + /// + [DataContract] + public class Base2Reply + { + /// + /// 是否成功 + /// + [DataMember] + public bool Success { get; set; } + + /// + /// 执行码 + /// + [DataMember] + public int Code { get; set; } + + /// + /// 执行消息 + /// + [DataMember] + public string Msg { get; set; } + = string.Empty; + + /// + /// 数据类型 + /// + [DataMember] + public ReplyDataType DataType { get; set; } + = ReplyDataType.Object; + + /// + /// 数据主体 + /// + [DataMember] + public object? Data { get; set; } + + /// + /// 成功 + /// + /// + public void ReplySuccess(string data) + { + Success = true; + Code = 0; + Msg = "成功"; + Data = data; + } + + /// + /// 成功 + /// + public void ReplySuccess() + { + Success = true; + Code = 0; + Msg = "成功"; + } + + /// + /// 失败 + /// + public void ReplyFalt() + { + Success = false; + Code = -1; + Msg = "失败"; + } + + /// + /// 失败 + /// + /// + /// + public void ReplyFalt(string msg, string? data = null) + { + Success = false; + Code = -1; + Msg = msg; + Data = data; + } + } + + /// + /// 响应数据类型 + /// + public enum ReplyDataType + { + /// + /// 空类型 + /// + Empty, + + /// + /// 字符串类型 + /// + String, + + /// + /// 对象类型 + /// + Object, + + /// + /// 列表类型 + /// + ObjectList, + + /// + /// 动态对象类型 + /// + ExpandoObject, + + /// + /// 动态对象类型 + /// + ExpandoObjectList, + } + + /// + /// 响应列 + /// + public class ReplyColumn + { + public ReplyColumn() + { + } + + + public ReplyColumn(string name, string caption) + { + Name = name; + Caption = caption; + } + + /// + /// 列名 + /// + [DataMember] + public string Name { get; set; } + = string.Empty; + + /// + /// 列标题 + /// + [DataMember] + public string Caption { get; set; } + = string.Empty; + + /// + /// 列宽度 + /// + [DataMember] + public double Width { get; set; } + + /// + /// 是否可见 + /// + [DataMember] + public bool IsVisible { get; set; } + = true; + + /// + /// 格式化字符串 + /// + [DataMember] + public string Format { get; set; } + = string.Empty; + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Protocols/CoreImagesModel.cs b/SHH.MjpegPlayer/Protocols/CoreImagesModel.cs new file mode 100644 index 0000000..68243d6 --- /dev/null +++ b/SHH.MjpegPlayer/Protocols/CoreImagesModel.cs @@ -0,0 +1,129 @@ +using Core.Protocol; +using System.Runtime.Serialization; + +namespace Core.WcfProtocol +{ + [DataContract] + public class RegisterModelRequest + { + /// + /// 进程Id + /// + [DataMember] + public Int32 ProcId { get; set; } + + /// + /// 进程类型 + /// + [DataMember] + public Int32 ProcType { get; set; } + + /// + /// 进程通信号 + /// + [DataMember] + public Int32 ProcChannel { get; set; } + + /// + /// 进程启动时间 + /// + [DataMember] + public Int64 ProcStartTime { get; set; } + + /// + /// 接收消息端口 + /// + [DataMember] + public Int32 AcceptPort { get; set; } + } + + /// + /// 注册结果 + /// + [DataContract] + public class RegisterModelReply : Base2Reply + { + } + + [DataContract] + public class UploadImageRequest + { + /// + /// 唯一标识 + /// + [DataMember] + public Int64 Id { get; set; } + + /// + /// 设备 IP + /// + [DataMember] + public string IpAddress { get; set; } + = string.Empty; + + /// + /// 进程 ID + /// + [DataMember] + public Int32 ProcId { get; set; } + + /// + /// 图片序号 + /// + [DataMember] + public UInt64 Order { get; set; } + + /// + /// 名称 + /// + [DataMember] + public string Name { get; set; } + = string.Empty; + + /// + /// 类型 + /// + [DataMember] + public string Type { get; set; } + = string.Empty; + + /// + /// 时间 + /// + [DataMember] + public DateTime Time { get; set; } + + /// + /// 图片数据 + /// + [DataMember] + public byte[]? ImageBytes { get; set; } + + /// + /// 图像宽度 + /// + [DataMember] + public int ImageWidth { get; set; } + + /// + /// 图像高度 + /// + [DataMember] + public int ImageHeight { get; set; } + + /// + /// 图片数据 + /// + [DataMember] + public string ImageData { get; set; } + = string.Empty; + } + + /// + /// 图片上传回复 + /// + [DataContract] + public class UploadImageReply : Base2Reply + { + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Protocols/ICoreImagesService.cs b/SHH.MjpegPlayer/Protocols/ICoreImagesService.cs new file mode 100644 index 0000000..dc878fb --- /dev/null +++ b/SHH.MjpegPlayer/Protocols/ICoreImagesService.cs @@ -0,0 +1,26 @@ +using CoreWCF; + +namespace Core.WcfProtocol +{ + /// + /// CoreImagesService 接口 + /// + [ServiceContract] + public interface ICoreImagesService + { + /// + /// 上传图片 + /// + /// + /// + [OperationContract] + UploadImageReply UploadImage(UploadImageRequest req); + + /// + /// 上传图片无返回结果 + /// + /// + [OperationContract(IsOneWay = true)] + void UploadImageOneWay(UploadImageRequest req); + } +} diff --git a/SHH.MjpegPlayer/Protocols/IMjpegImagesService.cs b/SHH.MjpegPlayer/Protocols/IMjpegImagesService.cs new file mode 100644 index 0000000..96d98f5 --- /dev/null +++ b/SHH.MjpegPlayer/Protocols/IMjpegImagesService.cs @@ -0,0 +1,21 @@ +using Core.WcfProtocol; +using CoreWCF; +using SHH.MjpegPlayer; + +namespace Player.MJPEG +{ + /// + /// IMjpegImagesService 接口 + /// + [ServiceContract] + public interface IMjpegImagesService : ICoreImagesService + { + /// + /// 注册模型 + /// + /// + /// + [OperationContract] + MjpegPlayInfoReply GetRtspRtcPlayInfo(); + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Protocols/MjpegPlayInfoReply.cs b/SHH.MjpegPlayer/Protocols/MjpegPlayInfoReply.cs new file mode 100644 index 0000000..766ce34 --- /dev/null +++ b/SHH.MjpegPlayer/Protocols/MjpegPlayInfoReply.cs @@ -0,0 +1,61 @@ +using Core.Protocol; +using System.Runtime.Serialization; + +namespace SHH.MjpegPlayer +{ + /// + /// Mjpeg播放信息回复 + /// + [DataContract] + public class MjpegPlayInfoReply : BaseReply + { + /// + /// 返回的信息集合 + /// + [DataMember] + public List Infos { get; set; } + = new List(); + } + + public class MjpegPlayInfo + { + /// + /// 摄像头 ID + /// + [DataMember] + public Int32 CameraId { get; set; } + + /// + /// 分析类型代码 + /// + [DataMember] + public int AITypeCode { get; set; } + + /// + /// 分析类型 + /// + [DataMember] + public string AIType { get; set; } + = string.Empty; + + /// + /// Rtsp 端口 + /// + [DataMember] + public Int32 RtspPort { get; set; } + + /// + /// 用户名 + /// + [DataMember] + public string Account { get; set; } + = string.Empty; + + /// + /// 密码 + /// + [DataMember] + public string Password { get; set; } + = string.Empty; + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/SHH.MjpegPlayer.csproj b/SHH.MjpegPlayer/SHH.MjpegPlayer.csproj new file mode 100644 index 0000000..72ec479 --- /dev/null +++ b/SHH.MjpegPlayer/SHH.MjpegPlayer.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/SHH.MjpegPlayer/Server/CoreImagesService.cs b/SHH.MjpegPlayer/Server/CoreImagesService.cs new file mode 100644 index 0000000..6b726d3 --- /dev/null +++ b/SHH.MjpegPlayer/Server/CoreImagesService.cs @@ -0,0 +1,75 @@ +using Ayay.SerilogLogs; +using Core.WcfProtocol; +using Serilog; + +namespace SHH.MjpegPlayer +{ + /// + /// CoreImagesService 服务 + /// + public class CoreImagesService : ICoreImagesService + { + private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core); + + #region Defines + + /// + /// 按秒统计 + /// + public static SumByTime _sumBySecond = new SumByTime(); + + #endregion + + #region UploadImage + + /// + /// 上传图片 + /// + /// + /// + public UploadImageReply UploadImage(UploadImageRequest req) + { + var reply = new UploadImageReply(); + + try + { + // 日志准备 + _sumBySecond.Refresh("UploadImage"); + + PrismMsg.Publish(req); + } + catch (Exception ex) + { + _sysLog.Warning($"上传图片失败, {ex.Message} {ex.StackTrace}"); + reply.ReplyFalt(ex.Message, ex.Source); + } + + reply.ReplySuccess(); + return reply; + } + + #endregion + + #region UploadImageOneWay + + /// + /// 上传图片 + /// + /// + public void UploadImageOneWay(UploadImageRequest req) + { + try + { + _sumBySecond.Refresh("UploadImage"); + + PrismMsg.Publish(req); + } + catch (Exception ex) + { + _sysLog.Warning($"上传图片失败, {ex.Message} {ex.StackTrace}"); + } + } + + #endregion + } +} diff --git a/SHH.MjpegPlayer/Server/MjpegHttpCmd.cs b/SHH.MjpegPlayer/Server/MjpegHttpCmd.cs new file mode 100644 index 0000000..a6dbc83 --- /dev/null +++ b/SHH.MjpegPlayer/Server/MjpegHttpCmd.cs @@ -0,0 +1,163 @@ +using Newtonsoft.Json; +using System.Net.Sockets; +using System.Text; + +namespace SHH.MjpegPlayer +{ + /// + /// MJPEG HTTP命令类 + /// + public class MjpegHttpCmd + { + #region DoHttpCmd + + public static bool DoHttpCmd(NetworkStream stream, + SessionInfo Info, string Cmd) + { + try + { + switch (Cmd) + { + case "view": + Info.Message = "执行 view 命令."; + DoHttpCmdView(stream, Info); + return true; + + case "task": + Info.Message = "执行 task 命令."; + DoHttpCmdTask(stream, Info); + return true; + } + + return false; + } + catch (Exception ex) + { + SendJson(stream, $"Command Failed: {ex.Message}", 400); + return true; + } + } + + #endregion + + #region DoHttpCmdView + + private static void DoHttpCmdView(NetworkStream stream, SessionInfo Info) + { + var sessions = new List(); + int iSessionCount = 0; + + var allSessions = MjpegStatics.Sessions.GetAllSessionInfos(); + + foreach (var sessionInfo in allSessions) + { + if (sessionInfo == null) continue; + + if (!string.IsNullOrEmpty(Info.DeviceId)) + { + if (!string.IsNullOrEmpty(sessionInfo.DeviceId) + && !sessionInfo.DeviceId.Equals(Info.DeviceId)) + continue; + } + + if (!string.IsNullOrEmpty(Info.TypeCode)) + { + if (!string.IsNullOrEmpty(sessionInfo.TypeCode) + && !sessionInfo.TypeCode.Equals(Info.TypeCode)) + continue; + } + + iSessionCount++; + sessions.Add(sessionInfo); + } + + var chns = new List(); + var imgChns = MjpegStatics.ImageChannels; + int iImgChanCount = 0; + + foreach (var kvp in imgChns.Channels) + { + var imgChannel = kvp.Value; + if (imgChannel == null) continue; + + if (!string.IsNullOrEmpty(Info.DeviceId)) + { + if (!imgChannel.DeviceId.ToString().Equals(Info.DeviceId)) + continue; + } + + if (!string.IsNullOrEmpty(Info.TypeCode)) + { + if (!imgChannel.Type.Equals(Info.TypeCode)) + continue; + } + + iImgChanCount++; + chns.Add(imgChannel); + } + + var result = new + { + webAccessCount = iSessionCount, + deviceChannelCount = iImgChanCount, + webAccessItems = sessions, + deviceChannels = chns + }; + + SendJson(stream, JsonConvert.SerializeObject(result, Formatting.Indented)); + } + + #endregion + + #region DoHttpCmdTask + + private static void DoHttpCmdTask(NetworkStream stream, SessionInfo Info) + { + // [Optimized]: 直接从 TaskManager 获取实时任务快照,避免遍历旧的静态字典 + var activeTasks = TaskManager.RunningTasks.Values.ToList(); + + int iTaskCount = activeTasks.Count; + int iMjpegServerListenCount = activeTasks.Count(t => t.Name.Contains("MjpegServer-")); + + var result = new + { + taskCount = iTaskCount, + portListenCount = iMjpegServerListenCount, + // 映射为前端需要的格式 + taskItems = activeTasks.Select(t => new { t.Name, t.Type }) + }; + + // 使用 Newtonsoft.Json 或 System.Text.Json 输出 + SendJson(stream, JsonConvert.SerializeObject(result, Formatting.Indented)); + } + + #endregion + + #region Helper + + private static void SendJson(NetworkStream stream, string json, int code = 200) + { + try + { + string statusLine = code == 200 ? "200 OK" : "400 Bad Request"; + + // [修复] 添加 CORS 头,允许诊断页面跨域访问 + byte[] response = Encoding.UTF8.GetBytes( + $"HTTP/1.1 {statusLine}\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + "Access-Control-Allow-Methods: GET, POST\r\n" + + "Content-Type: application/json; charset=utf-8\r\n" + + $"Content-Length: {Encoding.UTF8.GetByteCount(json)}\r\n\r\n" + + json + ); + + stream.Write(response, 0, response.Length); + stream.Flush(); + stream.Close(); + } + catch { } + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Server/MjpegImagesService.cs b/SHH.MjpegPlayer/Server/MjpegImagesService.cs new file mode 100644 index 0000000..c13029b --- /dev/null +++ b/SHH.MjpegPlayer/Server/MjpegImagesService.cs @@ -0,0 +1,28 @@ +using Player.MJPEG; + +namespace SHH.MjpegPlayer +{ + /// + /// MjpegImagesService 服务 + /// + public class MjpegImagesService : CoreImagesService, IMjpegImagesService + { + #region GetRtspRtcPlayInfo + + /// + /// 获取 RtspRtc 播放信息 + /// + /// + public MjpegPlayInfoReply GetRtspRtcPlayInfo() + { + var reply = new MjpegPlayInfoReply(); + + // 发送消息 + PrismMsg.Publish(reply); + + return reply; + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Server/MjpegServer.cs b/SHH.MjpegPlayer/Server/MjpegServer.cs new file mode 100644 index 0000000..b59ad3f --- /dev/null +++ b/SHH.MjpegPlayer/Server/MjpegServer.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Net.Sockets; + +namespace SHH.MjpegPlayer +{ + /// + /// Mjpeg 服务 + /// + public class MjpegServer + { + // [修复] 静态列表管理监听器,支持优雅停止 + private static readonly List _listeners = new List(); + private static readonly object _lock = new object(); + + /// + /// 启动服务 + /// + /// + public static void Start(int port) + { + try + { + // 示例:在 MjpegServer 初始化循环中调用 + TaskManager.Run($"MjpegServer-{port}", "Network", async (token) => + { + // [Modified]: 使用 TaskManager 托管,支持外部取消令牌 token + try + { + var cfg = MjpegStatics.Cfg; + IPAddress ipAddress = IPAddress.Any; + + if (!string.IsNullOrEmpty(cfg.SvrMjpegIp) && IPAddress.TryParse(cfg.SvrMjpegIp, out var parsedIp)) + { + ipAddress = parsedIp; + } + + var server = new TcpListener(ipAddress, port); + lock (_lock) _listeners.Add(server); + + server.Start(); + // Logs.LogInformation... + + try + { + // [Modified]: 检查取消令牌和全局运行状态 + while (!token.IsCancellationRequested) + { + try + { + // 使用 AcceptTcpClientAsync 的重载或在外部检查 + var client = await server.AcceptTcpClientAsync(); + if (client == null) continue; + + var session = new MjpegSession(); + session.Create(client); + } + catch (Exception ex) + { + // [修复] 异常防暴 + await Task.Delay(1000, token); + } + } + } + finally + { + // [修复] 任务退出清理 + try { server.Stop(); } catch { } + lock (_lock) _listeners.Remove(server); + } + } + catch (Exception ex) + { + // Logs.LogError... 捕获初始化异常 + } + }); + } + catch (Exception ex) + { + //Logs.LogError(ex.Message, ex.StackTrace); + } + } + + /// + /// 停止所有服务 (新增) + /// + public static void StopAll() + { + lock (_lock) + { + foreach (var server in _listeners) + { + try { server.Stop(); } catch { } + } + _listeners.Clear(); + } + } + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Server/MjpegSession.cs b/SHH.MjpegPlayer/Server/MjpegSession.cs new file mode 100644 index 0000000..2ade36f --- /dev/null +++ b/SHH.MjpegPlayer/Server/MjpegSession.cs @@ -0,0 +1,341 @@ +using Ayay.SerilogLogs; +using Core.WcfProtocol; +using Serilog; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace SHH.MjpegPlayer +{ + /// + /// Mjpeg 会话工作单元 + /// + public class MjpegSession : IDisposable + { + private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core); + + #region Counter + + private SumByTime _sumBySecond = new SumByTime(); + /// + /// 计数器 + /// + public SumByTime Counter => _sumBySecond; + + #endregion + + #region Info + + /// + /// 基础信息 + /// + public SessionInfo Info { get; private set; } + + #endregion + + #region Cmd + + /// + /// 命令 + /// + public string? Cmd { get; set; } + + #endregion + + // [修复] 引入 Disposed 标志位 + private volatile bool _isDisposed = false; + + #region Constructor + + /// + /// 构造函数 + /// + public MjpegSession() + { + Info = new SessionInfo + { + Counter = _sumBySecond + }; + } + + #endregion + + #region DoHttpHeader + + private bool DoHttpHeader(NetworkStream stream) + { + try + { + byte[] buffer = new byte[4096]; + int totalBytesRead = 0; + string httpRequest = string.Empty; + + while (totalBytesRead < buffer.Length) + { + int bytesRead = stream.Read(buffer, totalBytesRead, buffer.Length - totalBytesRead); + if (bytesRead == 0) return false; + + totalBytesRead += bytesRead; + httpRequest = Encoding.ASCII.GetString(buffer, 0, totalBytesRead); + if (httpRequest.Contains("\r\n\r\n")) break; + } + + if (!httpRequest.Contains("\r\n\r\n")) return false; + + if (!string.IsNullOrEmpty(httpRequest)) + { + int queryStartIndex = httpRequest.IndexOf('?'); + if (queryStartIndex > -1) + { + int spaceIndex = httpRequest.IndexOf(' ', queryStartIndex); + if (spaceIndex == -1) spaceIndex = httpRequest.Length; + string queryString = httpRequest.Substring(queryStartIndex + 1, spaceIndex - queryStartIndex - 1); + var queryParams = System.Web.HttpUtility.ParseQueryString(queryString); + if (queryParams != null) + { + Info.DeviceId = queryParams["id"]; + Info.TypeCode = queryParams["typeCode"]; + Cmd = queryParams["cmd"]; + } + } + } + + if (string.IsNullOrEmpty(Cmd)) + { + if (string.IsNullOrEmpty(Info.DeviceId) || string.IsNullOrEmpty(Info.TypeCode)) + { + SendErrorResponse(stream, "错误:缺少必要参数(id 或 typeCode)"); + return false; + } + } + else + { + if (MjpegHttpCmd.DoHttpCmd(stream, Info, Cmd)) return false; + } + return true; + } + catch (Exception ex) + { + SendErrorResponse(stream, $"解析异常: {ex.Message}"); + return false; + } + } + + private void SendErrorResponse(NetworkStream stream, string msg) + { + try + { + byte[] response = Encoding.UTF8.GetBytes( + $"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{msg}"); + stream.Write(response, 0, response.Length); + stream.Flush(); + } + catch { } + } + + #endregion + + #region Create + + /// + /// 创建会话 + /// + public void Create(TcpClient client) + { + try + { + if (Info == null) return; + Info.AcceptTime = DateTime.Now; + // 初始化最近接收时间,避免刚连接就被判定为超时 + LastRecImgTime = DateTime.Now; + + Task.Run(() => { DoWorkTask(client); }); + } + catch (Exception ex) + { + _sysLog.Error($"MjpegSession Create Exception, 异常信息:{ex.Message}, {ex.StackTrace}"); + } + } + + #endregion + + #region DoWorkTask + + private void DoWorkTask(TcpClient client) + { + try + { + using (var stream = client.GetStream()) + { + // 设置写入超时 3秒 + stream.WriteTimeout = 3000; + + #region 等待连接, 处理地址栏参数 + + int iLoc = 0; + while (!client.Connected) + { + Thread.Sleep(50); + if (++iLoc > 60) return; + } + + try + { + if (client.Client?.RemoteEndPoint is IPEndPoint endpoint) + { + Info.ClientIp = endpoint.Address.ToString(); + Info.ClientPort = endpoint.Port; + } + } + catch { } + + if (!DoHttpHeader(stream)) return; + + #endregion + + MjpegStatics.Sessions.AddSession(this); + + byte[] header = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\n" + + "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n"); + stream.Write(header, 0, header.Length); + + var frameInterval = MjpegStatics.Cfg.FrameInterval; + if (frameInterval < 1 || frameInterval > 500) frameInterval = 125; + + Stopwatch stopwatch = Stopwatch.StartNew(); + UploadImageRequest? lastProcItem = null; + + byte[] boundaryBytes = Encoding.ASCII.GetBytes("\r\n--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "); + byte[] doubleNewLine = Encoding.ASCII.GetBytes("\r\n\r\n"); + + while (client.Connected && !_isDisposed) + { + try + { + // [新增] 僵尸连接熔断机制:如果源头超过 60 秒没有新图片,主动断开连接 + if ((DateTime.Now - LastRecImgTime).TotalSeconds > 60) + { + _sysLog.Warning($"会话超时断开 (源头无数据 > 60s): {Info.Key} - Client: {Info.ClientIp}"); + break; + } + + stopwatch.Restart(); + + if (_lastRecObj == null || _lastRecObj.ImageBytes == null) + { + Info.Message = "等待图片数据抵达"; + Thread.Sleep(40); + continue; + } + + Info.Message = "视频流播放中"; + if (lastProcItem != null && lastProcItem != _lastRecObj) + { + _sumBySecond.Refresh("有效帧数"); + } + lastProcItem = _lastRecObj; + + // 如果图片太旧(超过3秒),认为是滞后数据,暂时不发 + if ((DateTime.Now - _lastRecObj.Time).TotalSeconds > 3) + { + Thread.Sleep(40); + continue; + } + + byte[] imageData = _lastRecObj.ImageBytes; + + stream.Write(boundaryBytes, 0, boundaryBytes.Length); + byte[] lengthBytes = Encoding.ASCII.GetBytes(imageData.Length.ToString()); + stream.Write(lengthBytes, 0, lengthBytes.Length); + stream.Write(doubleNewLine, 0, doubleNewLine.Length); + stream.Write(imageData, 0, imageData.Length); + + _sumBySecond.Refresh("播放帧数"); + + stopwatch.Stop(); + var needSleep = frameInterval - (int)stopwatch.ElapsedMilliseconds; + if (needSleep > 0) Thread.Sleep(needSleep); + } + catch (Exception ex) + { + _sysLog.Warning($"播放连接断开, : {ex.Message}"); + break; + } + } + } + } + catch (Exception ex) + { + _sysLog.Error($"异常信息:{ex.Message}, {ex.StackTrace}"); + } + finally + { + Dispose(); + if (client != null) + { + try { client.Close(); client.Dispose(); } catch { } + } + } + } + + #endregion + + #region DoImageProc + + private UInt64 _lastPlayImageOrder = 0; + private UploadImageRequest? _lastRecObj = null; + + /// + /// 处置图片 + /// + public void DoImageProc(UploadImageRequest req) + { + try + { + LastRecImgTime = DateTime.Now; + _sumBySecond.Refresh("接收帧数"); + if (req == null || req.ImageBytes == null || req.ImageBytes.Length < 100) return; + + // 防止死锁:序号重置检测 + if (req.Order < _lastPlayImageOrder) + { + if (_lastPlayImageOrder > 100 && req.Order < 100) + { + _lastPlayImageOrder = req.Order; + } + else + { + return; + } + } + + _lastPlayImageOrder = req.Order; + _lastRecObj = req; + } + catch (Exception ex) + { + _sysLog.Error($"异常信息:{ex.Message}, {ex.StackTrace}"); + } + } + + /// + /// 最近收到图片时间 + /// + public DateTime LastRecImgTime { get; set; } = DateTime.MinValue; + + #endregion + + #region Dispose + + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + MjpegStatics.Sessions.RemoveSession(this); + } + + #endregion + } +} diff --git a/SHH.MjpegPlayer/Server/MjpegSessions.cs b/SHH.MjpegPlayer/Server/MjpegSessions.cs new file mode 100644 index 0000000..b4f7ad3 --- /dev/null +++ b/SHH.MjpegPlayer/Server/MjpegSessions.cs @@ -0,0 +1,116 @@ +using Core.WcfProtocol; +using System.Collections.Concurrent; + +namespace SHH.MjpegPlayer; + +/// +/// 服务器会话集合 (线程安全重构版) +/// +public class MjpegSessions +{ + // 核心改变:使用字典建立索引,Key = DeviceId#TypeCode + // 这样可以将查找特定摄像头的复杂度从 O(N) 降低到 O(1) + private readonly ConcurrentDictionary> _sessionMap + = new ConcurrentDictionary>(); + + /// + /// 构造函数 + /// + public MjpegSessions() + { + PrismMsg.Subscribe(ProcUploadImageRequest); + } + + /// + /// 优化后的图片分发逻辑 (解决 O(N) 和 线程安全问题) + /// + /// + public void ProcUploadImageRequest(UploadImageRequest req) + { + try + { + string key = $"{req.Id}#{req.Type}"; + + // 1. 更新通道信息 (ImageChannels 现已是线程安全的) + var chn = MjpegStatics.ImageChannels.Do(req, key); + + // 2. O(1) 快速查找关注该流的会话列表 + if (_sessionMap.TryGetValue(key, out var targetSessions)) + { + // 必须加锁,防止遍历时 List 被其他线程(如 AddSession/RemoveSession) 修改 + lock (targetSessions) + { + // 倒序遍历,方便在需要时移除失效会话 + for (var i = targetSessions.Count - 1; i >= 0; i--) + { + var session = targetSessions[i]; + session.DoImageProc(req); + } + } + + if (chn != null) chn.IsPlaying = targetSessions.Count > 0; + } + else + { + if (chn != null) chn.IsPlaying = false; + } + } + catch (Exception ex) + { + //Logs.LogWarning(ex.Message, ex.StackTrace); + } + } + + /// + /// 添加会话 + /// + /// + public void AddSession(MjpegSession session) + { + if (session?.Info?.Key == null) return; + + // 使用 GetOrAdd 确保线程安全地获取或创建 List + var list = _sessionMap.GetOrAdd(session.Info.Key, _ => new List()); + + lock (list) + { + list.Add(session); + } + } + + /// + /// 移除会话 + /// + /// + public void RemoveSession(MjpegSession session) + { + if (session?.Info?.Key == null) return; + + if (_sessionMap.TryGetValue(session.Info.Key, out var list)) + { + lock (list) + { + list.Remove(session); + } + } + } + + /// + /// 获取当前所有会话信息的快照 (用于 HTTP API 统计与展示) + /// [新增] 此方法替代旧版直接访问 Sessions 列表,防止 HTTP 线程与 MJPEG 线程发生冲突 + /// + public List GetAllSessionInfos() + { + var result = new List(); + // 遍历字典,线程安全地收集所有 Info + foreach (var kvp in _sessionMap) + { + // 对内部 List 加锁,确保复制过程不被打断 + lock (kvp.Value) + { + result.AddRange(kvp.Value.Select(s => s.Info)); + } + } + return result; + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Server/RtmpPushServer.cs b/SHH.MjpegPlayer/Server/RtmpPushServer.cs new file mode 100644 index 0000000..9c1cf6c --- /dev/null +++ b/SHH.MjpegPlayer/Server/RtmpPushServer.cs @@ -0,0 +1,163 @@ +using Ayay.SerilogLogs; +using Serilog; + +namespace SHH.MjpegPlayer +{ + /// + /// RTMP 推流参数同步服务器 + /// 职责:定期将本地图片通道信息同步至流媒体服务器,并获取最新的 RTMP 推流地址。 + /// + public class RtmpPushServer + { + private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core); + + #region Instance + + /// + /// 获取 RTMP 推流处理器的全局单例实例 + /// + public static RtmpPushServer Instance { get; } = new RtmpPushServer(); + + // 私有构造函数防止外部 new + private RtmpPushServer() { } + + #endregion + + #region Start + + /// + /// 启动 RTMP 推流任务 (对接新架构 TaskManager) + /// + public void Start() + { + // Optimized: 使用 TaskManager.Run 替代旧的线程启动方式,实现任务可视化管理 + TaskManager.Run("RtmpPushSync", "Monitor", async (token) => + { + try + { + _sysLog.Information("RTMP 推流同步任务正在启动..."); + + // 1. 初始化延迟:稍作延迟,等待系统其他组件初始化完成 + await Task.Delay(2000, token); + + // 2. 配置校验 + if (!MjpegStatics.Cfg.UseRtmpServer) + { + _sysLog.Warning("配置未启用 RTMP 服务,推流任务已跳过"); + return; + } + + #region 启动连接检测 + + bool isConnected = false; + while (!isConnected && !token.IsCancellationRequested) + { + try + { + var cfg = MjpegStatics.Cfg; + var testItems = new List { new { deviceIp = "", deviceId = "", algCode = "" } }; + + var result = testItems.PostJson(cfg.RtmpServerDjhUri); + if (result != null && result.IsSuccess) + { + _sysLog.Information("流媒体服务接口检测通过: {Uri}", cfg.RtmpServerDjhUri); + isConnected = true; + } + else + { + _sysLog.Warning("检测流媒体服务接口失败, 30秒后再试... Uri: {Uri}", cfg.RtmpServerDjhUri); + await Task.Delay(30000, token); + } + } + catch (Exception ex) + { + _sysLog.Error(ex, "流媒体服务接口检测异常"); + await Task.Delay(5000, token); + } + } + + #endregion + + #region 周期性同步任务 (代替 OnDoTaskAction) + + while (!token.IsCancellationRequested) + { + // 执行业务同步逻辑 + await SyncRtmpParametersAsync(token); + + // 每 50ms 执行一次 (对应原 Start 中的 50ms 频率) + await Task.Delay(50, token); + } + + #endregion + } + catch (OperationCanceledException) + { + _sysLog.Information("RTMP 推流同步任务已正常取消"); + } + catch (Exception ex) + { + _sysLog.Fatal(ex, "RTMP 推流任务发生致命错误"); + } + }); + } + + #endregion + + #region 核心业务逻辑 + + private async Task SyncRtmpParametersAsync(CancellationToken token) + { + try + { + var cfg = MjpegStatics.Cfg; + var imgChns = MjpegStatics.ImageChannels; + var pushItems = new List(); + + // 构建上报数据 + foreach (var kvp in imgChns.Channels) + { + if (token.IsCancellationRequested) return; + + var imgChn = kvp.Value; + if (imgChn == null || !imgChn.UseRtmp) continue; + + pushItems.Add(new + { + deviceIp = string.IsNullOrEmpty(imgChn.IpAddress) ? "127.0.0.1" : imgChn.IpAddress, + deviceId = imgChn.DeviceId.ToString(), + algCode = imgChn.Type + }); + } + + if (pushItems.Count > 0) + { + // 使用 await 配合异步扩展,释放线程池线程 + // 如果你的 NetHttpExtension 已支持异步,则直接 await。 + // 否则使用 await Task.Run(() => pushItems.PostJson(...)) 暂时过渡。 + var result = await pushItems.PostJsonAsync(cfg.RtmpServerDjhUri, 2000); + + if (result?.rtmpVoList != null && result.IsSuccess) + { + foreach (var item in result.rtmpVoList) + { + var channel = imgChns.Get(item.deviceId, item.algCode); + if (channel != null && channel.RtmpUri != item.rtmp) + { + // 假设后续我们会统计实例中的成功次数 + channel.RtmpUri = item.rtmp; + } + } + } + } + } + catch (Exception ex) + { + // 使用统一的 Serilog 对象输出结构化日志 + _sysLog.Error(ex, "SyncRtmpParametersAsync 执行异常"); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.MjpegPlayer/Server/TaskManager.cs b/SHH.MjpegPlayer/Server/TaskManager.cs new file mode 100644 index 0000000..102853c --- /dev/null +++ b/SHH.MjpegPlayer/Server/TaskManager.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; + +namespace SHH.MjpegPlayer; + +/// +/// 任务状态信息载荷 +/// +public record TaskMetadata(string Name, string Type, DateTime StartTime); + +/// +/// 任务管理器:替代原 CoreTaskRun 功能 +/// 职责:记录运行中的异步任务,支持状态检索和统一取消 +/// +public static class TaskManager +{ + // 存储运行中的任务及其元数据 + public static readonly ConcurrentDictionary RunningTasks = new(); + + // 存储取消令牌,用于停止特定任务 + private static readonly ConcurrentDictionary _tokens = new(); + + /// + /// 注册并运行一个受控任务 + /// + public static void Run(string taskName, string taskType, Func action) + { + var cts = new CancellationTokenSource(); + _tokens[taskName] = cts; + + var metadata = new TaskMetadata(taskName, taskType, DateTime.Now); + RunningTasks[taskName] = metadata; + + // 启动异步任务 + Task.Run(async () => + { + try + { + await action(cts.Token); + } + finally + { + // Optimized: 任务结束(无论正常还是异常)必须清理资源 + RunningTasks.TryRemove(taskName, out _); + _tokens.TryRemove(taskName, out _); + } + }, cts.Token); + } +} \ No newline at end of file