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