From c438edfa0d45851e51710575ba7942094d796f5b Mon Sep 17 00:00:00 2001 From: wilson Date: Wed, 21 Jan 2026 19:03:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Mjpegplayer=20=E7=94=A8?= =?UTF-8?q?=E6=9D=A5=E6=92=AD=E6=94=BE=20Web=20=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Ayay.Solution.sln | 50 +-- SHH.CameraSdk/Configs/ServiceConfig.cs | 17 +- SHH.CameraSdk/Core/Manager/CameraManager.cs | 6 +- .../Core/Services/ConnectivitySentinel.cs | 2 +- SHH.CameraSdk/Drivers/DaHua/DahuaPlaySDK.cs | 21 + .../Drivers/DaHua/DahuaVideoSource.cs | 28 ++ .../Drivers/HikVision/HikPlayMethods.cs | 10 +- .../Drivers/HikVision/HikVideoSource.cs | 33 +- SHH.CameraSdk/SHH.CameraSdk.csproj | 2 +- SHH.CameraService/Bootstrapper.cs | 98 +++-- .../GrpcImpls/Handlers/DeviceConfigHandler.cs | 2 +- .../GrpcImpls/Handlers/DeviceStatusHandler.cs | 80 +++- .../GrpcImpls/Handlers/RemoveCameraHandler.cs | 2 +- SHH.CameraService/Program.cs | 13 + SHH.CameraService/SHH.CameraService.csproj | 6 +- .../Utils/ServiceCollectionExtensions.cs | 21 +- SHH.CameraService/notifyIcon.ico | Bin 0 -> 136606 bytes .../Dtos}/CameraConfigDto.cs | 129 ++---- .../Dtos/CameraConfigSubscribeDto.cs | 22 + .../Dtos}/CommandPayload.cs | 21 +- .../Images}/VideoPayload.cs | 47 +-- SHH.Contracts.Grpc/Payloads/ProtocolCodes.cs | 47 +++ .../Payloads}/RegisterPayload.cs | 58 +-- .../Payloads/StatusEventPayload.cs | 23 ++ SHH.Contracts.Grpc/SHH.Contracts.Grpc.csproj | 1 + SHH.Contracts/CommandResult.cs | 86 ---- SHH.Contracts/Commands/DeviceStatusEvent.cs | 49 --- SHH.Contracts/Commands/ProtocolHeaders.cs | 15 - SHH.Contracts/SHH.Contracts.csproj | 13 - SHH.MjpegPlayer/Bootstrapper.cs | 172 ++++++++ .../Core/Extensions/NetHttpExtension.cs | 120 ++++++ .../Core/Extensions/NetPortExtension.cs | 190 +++++++++ .../Core/Extensions/ProcessExtension.cs | 197 +++++++++ SHH.MjpegPlayer/Core/ImageChannel.cs | 61 +++ SHH.MjpegPlayer/Core/ImageChannels.cs | 72 ++++ SHH.MjpegPlayer/Core/JsonConfig.cs | 94 +++++ SHH.MjpegPlayer/Core/Models/CfgRtmpReply.cs | 17 + SHH.MjpegPlayer/Core/Models/EIdSys.cs | 35 ++ SHH.MjpegPlayer/Core/Models/JsonConfigUris.cs | 18 + SHH.MjpegPlayer/Core/Models/MjpegConfig.cs | 43 ++ SHH.MjpegPlayer/Core/Models/RtmpVo.cs | 17 + SHH.MjpegPlayer/Core/Models/SessionInfo.cs | 100 +++++ SHH.MjpegPlayer/Core/Models/SumByTime.cs | 190 +++++++++ SHH.MjpegPlayer/Core/PrismMsg.cs | 68 ++++ SHH.MjpegPlayer/Core/Watchs/MemoryWatchdog.cs | 101 +++++ .../GrpcImpls/Core/ConcurrentHashSet.cs | 22 + .../GrpcImpls/Handlers/DeviceConfigHandler.cs | 106 +++++ .../GrpcImpls/Handlers/DeviceStatusHandler.cs | 48 +++ .../GrpcImpls/Handlers/GatewayService.cs | 156 ++++++++ .../GrpcImpls/Handlers/GrpcSessionManager.cs | 108 +++++ .../GrpcImpls/Handlers/MessageBus.cs | 137 +++++++ .../ImageProcs/ImageMonitorController.cs | 37 ++ .../ImageProcs/ImagePayloadConverter.cs | 78 ++++ SHH.MjpegPlayer/GrpcServerManager.cs | 62 +++ SHH.MjpegPlayer/MJpegPlayer.Solution.sln | 37 ++ SHH.MjpegPlayer/MjpegStatics.cs | 26 ++ SHH.MjpegPlayer/Program.cs | 107 +++++ SHH.MjpegPlayer/Protocols/Base2Reply.cs | 378 ++++++++++++++++++ SHH.MjpegPlayer/Protocols/CoreImagesModel.cs | 129 ++++++ .../Protocols/ICoreImagesService.cs | 26 ++ .../Protocols/IMjpegImagesService.cs | 21 + .../Protocols/MjpegPlayInfoReply.cs | 61 +++ SHH.MjpegPlayer/SHH.MjpegPlayer.csproj | 22 + SHH.MjpegPlayer/Server/CoreImagesService.cs | 75 ++++ SHH.MjpegPlayer/Server/MjpegHttpCmd.cs | 163 ++++++++ SHH.MjpegPlayer/Server/MjpegImagesService.cs | 28 ++ SHH.MjpegPlayer/Server/MjpegServer.cs | 98 +++++ SHH.MjpegPlayer/Server/MjpegSession.cs | 341 ++++++++++++++++ SHH.MjpegPlayer/Server/MjpegSessions.cs | 116 ++++++ SHH.MjpegPlayer/Server/RtmpPushServer.cs | 163 ++++++++ SHH.MjpegPlayer/Server/TaskManager.cs | 48 +++ 71 files changed, 4538 insertions(+), 452 deletions(-) create mode 100644 SHH.CameraService/notifyIcon.ico rename {SHH.Contracts => SHH.Contracts.Grpc/Dtos}/CameraConfigDto.cs (52%) create mode 100644 SHH.Contracts.Grpc/Dtos/CameraConfigSubscribeDto.cs rename {SHH.Contracts/Commands => SHH.Contracts.Grpc/Dtos}/CommandPayload.cs (86%) rename {SHH.Contracts/Commands => SHH.Contracts.Grpc/Images}/VideoPayload.cs (68%) create mode 100644 SHH.Contracts.Grpc/Payloads/ProtocolCodes.cs rename {SHH.Contracts/Commands => SHH.Contracts.Grpc/Payloads}/RegisterPayload.cs (50%) create mode 100644 SHH.Contracts.Grpc/Payloads/StatusEventPayload.cs delete mode 100644 SHH.Contracts/CommandResult.cs delete mode 100644 SHH.Contracts/Commands/DeviceStatusEvent.cs delete mode 100644 SHH.Contracts/Commands/ProtocolHeaders.cs delete mode 100644 SHH.Contracts/SHH.Contracts.csproj create mode 100644 SHH.MjpegPlayer/Bootstrapper.cs create mode 100644 SHH.MjpegPlayer/Core/Extensions/NetHttpExtension.cs create mode 100644 SHH.MjpegPlayer/Core/Extensions/NetPortExtension.cs create mode 100644 SHH.MjpegPlayer/Core/Extensions/ProcessExtension.cs create mode 100644 SHH.MjpegPlayer/Core/ImageChannel.cs create mode 100644 SHH.MjpegPlayer/Core/ImageChannels.cs create mode 100644 SHH.MjpegPlayer/Core/JsonConfig.cs create mode 100644 SHH.MjpegPlayer/Core/Models/CfgRtmpReply.cs create mode 100644 SHH.MjpegPlayer/Core/Models/EIdSys.cs create mode 100644 SHH.MjpegPlayer/Core/Models/JsonConfigUris.cs create mode 100644 SHH.MjpegPlayer/Core/Models/MjpegConfig.cs create mode 100644 SHH.MjpegPlayer/Core/Models/RtmpVo.cs create mode 100644 SHH.MjpegPlayer/Core/Models/SessionInfo.cs create mode 100644 SHH.MjpegPlayer/Core/Models/SumByTime.cs create mode 100644 SHH.MjpegPlayer/Core/PrismMsg.cs create mode 100644 SHH.MjpegPlayer/Core/Watchs/MemoryWatchdog.cs create mode 100644 SHH.MjpegPlayer/GrpcImpls/Core/ConcurrentHashSet.cs create mode 100644 SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceConfigHandler.cs create mode 100644 SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceStatusHandler.cs create mode 100644 SHH.MjpegPlayer/GrpcImpls/Handlers/GatewayService.cs create mode 100644 SHH.MjpegPlayer/GrpcImpls/Handlers/GrpcSessionManager.cs create mode 100644 SHH.MjpegPlayer/GrpcImpls/Handlers/MessageBus.cs create mode 100644 SHH.MjpegPlayer/GrpcImpls/ImageProcs/ImageMonitorController.cs create mode 100644 SHH.MjpegPlayer/GrpcImpls/ImageProcs/ImagePayloadConverter.cs create mode 100644 SHH.MjpegPlayer/GrpcServerManager.cs create mode 100644 SHH.MjpegPlayer/MJpegPlayer.Solution.sln create mode 100644 SHH.MjpegPlayer/MjpegStatics.cs create mode 100644 SHH.MjpegPlayer/Program.cs create mode 100644 SHH.MjpegPlayer/Protocols/Base2Reply.cs create mode 100644 SHH.MjpegPlayer/Protocols/CoreImagesModel.cs create mode 100644 SHH.MjpegPlayer/Protocols/ICoreImagesService.cs create mode 100644 SHH.MjpegPlayer/Protocols/IMjpegImagesService.cs create mode 100644 SHH.MjpegPlayer/Protocols/MjpegPlayInfoReply.cs create mode 100644 SHH.MjpegPlayer/SHH.MjpegPlayer.csproj create mode 100644 SHH.MjpegPlayer/Server/CoreImagesService.cs create mode 100644 SHH.MjpegPlayer/Server/MjpegHttpCmd.cs create mode 100644 SHH.MjpegPlayer/Server/MjpegImagesService.cs create mode 100644 SHH.MjpegPlayer/Server/MjpegServer.cs create mode 100644 SHH.MjpegPlayer/Server/MjpegSession.cs create mode 100644 SHH.MjpegPlayer/Server/MjpegSessions.cs create mode 100644 SHH.MjpegPlayer/Server/RtmpPushServer.cs create mode 100644 SHH.MjpegPlayer/Server/TaskManager.cs 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 0000000000000000000000000000000000000000..01495ab4316e623b5bca8537f38dffe96f6b628d GIT binary patch literal 136606 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 literal 0 HcmV?d00001 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