新增 Mjpegplayer 用来播放 Web 流
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,15 +409,15 @@ public class CameraManager : IDisposable, IAsyncDisposable
|
||||
/// <para>参数2: IsOnline (true=在线, false=离线)</para>
|
||||
/// <para>参数3: Reason (变更原因)</para>
|
||||
/// </summary>
|
||||
public event Action<long, bool, string>? OnDeviceStatusChanged;
|
||||
public event Action<long, string, bool, string>? OnDeviceStatusChanged;
|
||||
|
||||
/// <summary>
|
||||
/// [内部方法] 供 Sentinel 调用,触发事件冒泡
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
namespace SHH.CameraSdk;
|
||||
namespace SHH.CameraSdk;
|
||||
|
||||
/// <summary>
|
||||
/// 海康播放库 PlayCtrl.dll 的封装
|
||||
@@ -343,5 +341,11 @@ public static class HikPlayMethods
|
||||
[DllImport(DllName)]
|
||||
public static extern bool PlayM4_ResetSourceBuffer(int nPort);
|
||||
|
||||
/// <summary>
|
||||
/// [新增] 开启硬件解码
|
||||
/// </summary>
|
||||
[DllImport(DllName)]
|
||||
public static extern bool PlayM4_SetHardWareDecode(int nPort, int nMode);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj" />
|
||||
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />
|
||||
<ProjectReference Include="..\SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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<cfgEndpoints.Count; i++)
|
||||
// {
|
||||
// // 将 tcp:// 转换为 http:// 以适配 gRpc
|
||||
// string targetUrl = cfgEndpoints[i].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
|
||||
// {
|
||||
// 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}");
|
||||
//}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -18,7 +18,7 @@ public class DeviceConfigHandler : ICommandHandler
|
||||
/// <summary>
|
||||
/// 命令名称
|
||||
/// </summary>
|
||||
public string ActionName => ProtocolHeaders.Sync_Camera;
|
||||
public string ActionName => ProtocolCodes.Sync_Camera;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
|
||||
@@ -24,6 +24,9 @@ public class DeviceStatusHandler : BackgroundService
|
||||
// 状态存储:CameraId -> 状态载荷
|
||||
private readonly ConcurrentDictionary<string, StatusEventPayload> _stateStore = new();
|
||||
|
||||
// 记录上一次成功发送的状态快照,用于增量日志对比
|
||||
private readonly Dictionary<string, bool> _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
|
||||
/// <summary>
|
||||
/// SDK 状态变更回调
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace SHH.CameraService
|
||||
/// <summary>
|
||||
/// 指令名称
|
||||
/// </summary>
|
||||
public string ActionName => ProtocolHeaders.Remove_Camera;
|
||||
public string ActionName => ProtocolCodes.Remove_Camera;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
|
||||
@@ -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] 🚀 核心业务逻辑已激活, 设备管理器已就绪.");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<ApplicationIcon>notifyIcon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -14,6 +15,10 @@
|
||||
<None Remove="Dels\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="notifyIcon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.76.0">
|
||||
@@ -26,7 +31,6 @@
|
||||
<ProjectReference Include="..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj" />
|
||||
<ProjectReference Include="..\SHH.CameraSdk\SHH.CameraSdk.csproj" />
|
||||
<ProjectReference Include="..\SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj" />
|
||||
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<IEnumerable<StreamTarget>>(netTargets);
|
||||
services.AddHostedService<ImageMonitorController>();
|
||||
|
||||
// 动态注册 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<IHostedService>(sp => new GrpcSenderWorker(currentTarget));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
SHH.CameraService/notifyIcon.ico
Normal file
BIN
SHH.CameraService/notifyIcon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -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) ---
|
||||
/// <summary>
|
||||
/// 设备唯一标识
|
||||
/// </summary>
|
||||
|
||||
/// <summary>设备唯一标识</summary>
|
||||
[Required(ErrorMessage = "设备ID不能为空")]
|
||||
[Range(1, long.MaxValue, ErrorMessage = "设备ID必须为正整数")]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 设备友好名称
|
||||
/// </summary>
|
||||
/// <summary>设备友好名称</summary>
|
||||
[MaxLength(64, ErrorMessage = "设备名称长度不能超过64个字符")]
|
||||
public string Name { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 摄像头品牌类型 (0:HikVision, 1:Dahua, 2:RTSP...)
|
||||
/// </summary>
|
||||
/// <summary>摄像头品牌类型 (0:HikVision, 1:Dahua, 2:RTSP...)</summary>
|
||||
[Range(0, 10, ErrorMessage = "品牌类型值必须在0-10范围内")]
|
||||
public int Brand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 设备安装位置描述
|
||||
/// </summary>
|
||||
/// <summary>设备安装位置描述</summary>
|
||||
[MaxLength(128, ErrorMessage = "安装位置长度不能超过128个字符")]
|
||||
public string Location { get; set; }
|
||||
public string Location { get; set; } = string.Empty;
|
||||
|
||||
// --- 主板关联信息 (Metadata) ---
|
||||
|
||||
/// <summary>
|
||||
/// 关联主板IP地址
|
||||
/// </summary>
|
||||
/// <summary>关联主板IP地址</summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// 关联主板端口
|
||||
/// </summary>
|
||||
/// <summary>关联主板端口</summary>
|
||||
[Range(0, 65535, ErrorMessage = "主板端口号必须在1-65535范围内")]
|
||||
public int MainboardPort { get; set; } = 0;
|
||||
|
||||
// --- 核心连接 (Connectivity) - 修改此类参数触发冷重启 ---
|
||||
|
||||
/// <summary>
|
||||
/// 摄像头IP地址
|
||||
/// </summary>
|
||||
/// <summary>摄像头IP地址</summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// 登录用户名
|
||||
/// </summary>
|
||||
/// <summary>登录用户名</summary>
|
||||
[MaxLength(32, ErrorMessage = "用户名长度不能超过32个字符")]
|
||||
public string Username { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 登录密码
|
||||
/// </summary>
|
||||
/// <summary>登录密码</summary>
|
||||
[MaxLength(64, ErrorMessage = "密码长度不能超过64个字符")]
|
||||
public string Password { get; set; }
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SDK端口 (如海康默认8000)
|
||||
/// </summary>
|
||||
/// <summary>SDK端口 (如海康默认8000)</summary>
|
||||
[Range(1, 65535, ErrorMessage = "端口号必须在1-65535范围内")]
|
||||
public ushort Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通道号 (通常为1)
|
||||
/// </summary>
|
||||
/// <summary>通道号 (通常为1)</summary>
|
||||
[Range(0, 256, ErrorMessage = "通道号必须在0-256范围内")]
|
||||
public int ChannelIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 码流类型 (0:主码流, 1:子码流)
|
||||
/// </summary>
|
||||
/// <summary>码流类型 (0:主码流, 1:子码流)</summary>
|
||||
[Range(0, 1, ErrorMessage = "码流类型只能是0(主码流)或1(子码流)")]
|
||||
public int StreamType { get; set; }
|
||||
|
||||
// 渲染句柄 (通常下发时为0,由本地窗口绑定时再指定,或者此处仅作占位)
|
||||
/// <summary>渲染句柄 (通常下发时为0,由本地窗口绑定时再指定,或者此处仅作占位)</summary>
|
||||
public long RenderHandle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// RTSP流路径 (备用或非SDK模式使用)
|
||||
/// </summary>
|
||||
/// <summary>RTSP流路径 (备用或非SDK模式使用)</summary>
|
||||
[MaxLength(256, ErrorMessage = "RTSP地址长度不能超过256个字符")]
|
||||
public string RtspPath { get; set; }
|
||||
public string RtspPath { get; set; } = string.Empty;
|
||||
|
||||
// --- 运行时参数 (Runtime Options) - 支持热更新 ---
|
||||
|
||||
/// <summary>
|
||||
/// 是否使用灰度图 (用于AI分析场景加速)
|
||||
/// </summary>
|
||||
/// <summary>是否使用灰度图 (用于AI分析场景加速)</summary>
|
||||
public bool UseGrayscale { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用图像增强 (去噪/锐化等)
|
||||
/// </summary>
|
||||
/// <summary>是否启用图像增强 (去噪/锐化等)</summary>
|
||||
public bool EnhanceImage { get; set; } = true;
|
||||
|
||||
// --- 画面变换 (Transform) - 支持热更新 ---
|
||||
/// <summary>
|
||||
/// 是否允许图像压缩 (降低带宽占用)
|
||||
/// </summary>
|
||||
|
||||
/// <summary>是否允许图像压缩 (降低带宽占用)</summary>
|
||||
public bool AllowCompress { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许图像放大 (提升渲染质量)
|
||||
/// </summary>
|
||||
/// <summary>是否允许图像放大 (提升渲染质量)</summary>
|
||||
public bool AllowExpand { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 目标分辨率 (格式如 1920x1080,空则保持原图)
|
||||
/// </summary>
|
||||
/// <summary>目标分辨率 (格式如 1920x1080,空则保持原图)</summary>
|
||||
[RegularExpression(@"^\d+x\d+$", ErrorMessage = "分辨率格式必须为 宽度x高度 (如 1920x1080)")]
|
||||
public string TargetResolution { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 随配置一并下发的自动订阅请求
|
||||
/// </summary>
|
||||
/// <summary>随配置一并下发的自动订阅请求</summary>
|
||||
public List<CameraConfigSubscribeDto> AutoSubscriptions { get; set; }
|
||||
= new List<CameraConfigSubscribeDto>();
|
||||
|
||||
/// <summary>
|
||||
/// 是否立即执行
|
||||
/// </summary>
|
||||
/// <summary>是否立即执行</summary>
|
||||
[JsonProperty("ImmediateExecution")] // 确保 JSON 里的这个 key 能精准对应到这个属性
|
||||
public bool ImmediateExecution { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅项
|
||||
/// </summary>
|
||||
public class CameraConfigSubscribeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅标识
|
||||
/// </summary>
|
||||
public string AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订阅业务类型 SubscriptionType
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 要求的帧率:8帧或1帧
|
||||
/// </summary>
|
||||
public int TargetFps { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注
|
||||
/// </summary>
|
||||
public string Memo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否需要高清晰度
|
||||
/// </summary>
|
||||
public bool NeedHighDefinition { get; set; }
|
||||
= false;
|
||||
}
|
||||
}
|
||||
22
SHH.Contracts.Grpc/Dtos/CameraConfigSubscribeDto.cs
Normal file
22
SHH.Contracts.Grpc/Dtos/CameraConfigSubscribeDto.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>订阅项</summary>
|
||||
public class CameraConfigSubscribeDto
|
||||
{
|
||||
/// <summary>订阅标识 例如: "UI_Display" (界面显示), "AI_Analysis" (算法分析)</summary>
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>订阅业务类型 对应枚举 SubscriptionType 的整型值</summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
/// <summary>要求的传输帧率 要求的帧率:8 帧或 1 帧</summary>
|
||||
public int TargetFps { get; set; }
|
||||
|
||||
/// <summary>是否需要高清晰度流(主码流) true: 请求高分辨率主码流; false: 请求低分辨率子码流(默认)</summary>
|
||||
public string Memo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>是否需要高清晰度</summary>
|
||||
public bool NeedHighDefinition { get; set; }
|
||||
= false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
using MessagePack;
|
||||
using System;
|
||||
|
||||
namespace SHH.Contracts
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用指令请求载体 (Request)
|
||||
/// <para>用于 NetMQ 的 Request-Reply 或 Router-Dealer 模式</para>
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CommandPayload
|
||||
{
|
||||
#region --- 0. 协议自描述 ---
|
||||
@@ -16,7 +12,6 @@ namespace SHH.Contracts
|
||||
/// 协议类型标识
|
||||
/// <para>建议值: "COMMAND" 或 "指令包"</para>
|
||||
/// </summary>
|
||||
[Key(0)]
|
||||
public string Protocol { get; set; } = "COMMAND";
|
||||
|
||||
#endregion
|
||||
@@ -27,22 +22,19 @@ namespace SHH.Contracts
|
||||
/// 指令代码 (路由键)
|
||||
/// <para>示例: "PTZ", "RECORD_START", "SERVER_REGISTER"</para>
|
||||
/// </summary>
|
||||
[Key(1)]
|
||||
public string CmdCode { get; set; }
|
||||
public string CmdCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标对象 ID
|
||||
/// <para>示例: 摄像头ID "101",或者系统级指令填 "SYSTEM"</para>
|
||||
/// </summary>
|
||||
[Key(2)]
|
||||
public string TargetId { get; set; }
|
||||
public string TargetId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 业务参数 (JSON 字符串)
|
||||
/// <para>根据 CmdCode 的不同,反序列化为不同的 DTO (如 PtzControlDto)</para>
|
||||
/// </summary>
|
||||
[Key(3)]
|
||||
public string JsonParams { get; set; }
|
||||
public string JsonParams { get; set; } = string.Empty;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -52,13 +44,11 @@ namespace SHH.Contracts
|
||||
/// 请求追踪 ID (UUID)
|
||||
/// <para>核心字段:用于实现异步等待 (await)。回执包必须携带此 ID。</para>
|
||||
/// </summary>
|
||||
[Key(4)]
|
||||
public string RequestId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间戳
|
||||
/// </summary>
|
||||
[Key(5)]
|
||||
public DateTime Timestamp { get; set; } = DateTime.Now;
|
||||
|
||||
#endregion
|
||||
@@ -70,7 +60,6 @@ namespace SHH.Contracts
|
||||
/// <para>true: 发送端会 await 等待结果 (默认)</para>
|
||||
/// <para>false: 发后即忘 (Fire-and-Forget),服务端收到后不回发任何消息,减少带宽</para>
|
||||
/// </summary>
|
||||
[Key(6)]
|
||||
public bool RequireAck { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
@@ -79,14 +68,12 @@ namespace SHH.Contracts
|
||||
/// <para>1, 2...: 第N次重试</para>
|
||||
/// <para>服务端据此判断是否需要查重 (幂等性处理)</para>
|
||||
/// </summary>
|
||||
[Key(7)]
|
||||
public int RetryCount { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 消息过期时间 (Unix时间戳)
|
||||
/// <para>如果接收端收到时已经超过此时间,直接丢弃,不处理也不回复</para>
|
||||
/// </summary>
|
||||
[Key(8)]
|
||||
public long ExpireTime { get; set; }
|
||||
|
||||
#endregion
|
||||
@@ -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
|
||||
/// <summary>
|
||||
/// 视频数据传输契约(纯净版 POCO)
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class VideoPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
public VideoPayload()
|
||||
{
|
||||
SubscriberIds = new List<string>(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;
|
||||
|
||||
/// <summary>
|
||||
/// 采集时间戳 (Unix 毫秒)
|
||||
/// </summary>
|
||||
[Key(1)]
|
||||
/// <summary>采集时间戳 (Unix 毫秒)</summary>
|
||||
public long CaptureTimestamp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分发时间戳 (Unix 毫秒)
|
||||
/// </summary>
|
||||
[Key(2)]
|
||||
/// <summary>分发时间戳 (Unix 毫秒)</summary>
|
||||
public long DispatchTimestamp { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
/// <summary>原始图像宽度</summary>
|
||||
public int OriginalWidth { get; set; }
|
||||
|
||||
[Key(4)]
|
||||
/// <summary>原始图像高度</summary>
|
||||
public int OriginalHeight { get; set; }
|
||||
|
||||
[Key(5)]
|
||||
/// <summary>目标图像宽度</summary>
|
||||
public int TargetWidth { get; set; }
|
||||
|
||||
[Key(6)]
|
||||
/// <summary>目标图像高度</summary>
|
||||
public int TargetHeight { get; set; }
|
||||
|
||||
[Key(7)]
|
||||
/// <summary>订阅Ids</summary>
|
||||
public List<string> SubscriberIds { get; set; }
|
||||
|
||||
[Key(8)]
|
||||
/// <summary>诊断信息</summary>
|
||||
public Dictionary<string, object> Diagnostics { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 指示标志:是否存在原始图
|
||||
/// </summary>
|
||||
[Key(9)]
|
||||
public bool HasOriginalImage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 指示标志:是否存在处理图
|
||||
/// </summary>
|
||||
[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<VideoPayload>(json);
|
||||
}
|
||||
47
SHH.Contracts.Grpc/Payloads/ProtocolCodes.cs
Normal file
47
SHH.Contracts.Grpc/Payloads/ProtocolCodes.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// 协议代码定义常量类
|
||||
/// <para>职责:统一管理 gRpc 通讯中所涉及的协议大类 (Protocol) 与具体业务指令码 (CmdCode)</para>
|
||||
/// </summary>
|
||||
public static class ProtocolCodes
|
||||
{
|
||||
#region --- 1. 协议类型定义 (对应 Protocol 字段) ---
|
||||
|
||||
/// <summary>
|
||||
/// 基础指令协议头
|
||||
/// <para>用于标记该消息是一个业务控制指令</para>
|
||||
/// </summary>
|
||||
public const string Command = "Command";
|
||||
|
||||
/// <summary>
|
||||
/// 指令执行结果反馈协议头
|
||||
/// <para>用于分析节点执行完指令后,向主控端回执操作结果</para>
|
||||
/// </summary>
|
||||
public const string Command_Result = "Command_Result";
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 2. 业务指令码定义 (对应 CmdCode 字段) ---
|
||||
|
||||
/// <summary>
|
||||
/// 服务器注册指令
|
||||
/// <para>触发场景:节点启动时向主控端注册自身信息</para>
|
||||
/// </summary>
|
||||
public const string ServerRegister = "SERVER_REGISTER";
|
||||
|
||||
/// <summary>
|
||||
/// 同步摄像头配置指令
|
||||
/// <para>触发场景:节点上线全量同步、数据库摄像头信息变更增量同步</para>
|
||||
/// </summary>
|
||||
public static string Sync_Camera { get; } = "Sync_Camera";
|
||||
|
||||
/// <summary>
|
||||
/// 移除摄像头指令
|
||||
/// <para>触发场景:本地数据库删除摄像头后,通知远程节点停止相关流采集与分析</para>
|
||||
/// </summary>
|
||||
public static string Remove_Camera { get; } = "Remove_Camera";
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,33 @@
|
||||
using MessagePack;
|
||||
using System;
|
||||
|
||||
namespace SHH.Contracts
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务端身份注册信息 (DTO)
|
||||
/// <para>用于服务端主动连上客户端后,上报自身的端口和身份信息</para>
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class RegisterPayload
|
||||
{
|
||||
#region --- 0. 协议自描述 ---
|
||||
|
||||
/// <summary>
|
||||
/// 协议类型标识 (人工可读)
|
||||
/// </summary>
|
||||
[Key(0)]
|
||||
public string Protocol { get; set; } = ProtocolHeaders.ServerRegister;
|
||||
/// <summary>协议类型标识 (人工可读)</summary>
|
||||
public string Protocol { get; set; } = ProtocolCodes.ServerRegister;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 1. 身份标识 ---
|
||||
|
||||
/// <summary>
|
||||
/// 进程 ID (用于区分同一台机器上的多个实例)
|
||||
/// </summary>
|
||||
[Key(1)]
|
||||
/// <summary>进程 ID (用于区分同一台机器上的多个实例)</summary>
|
||||
public int ProcessId { get; set; }
|
||||
|
||||
/// <summary>调用进程 ID (用于区分同一台机器上的多个实例)</summary>
|
||||
public int InvokeProcId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实例唯一标识符
|
||||
/// <para>启动时通过命令行传入,例如 "Gateway_Factory_A"</para>
|
||||
/// </summary>
|
||||
[Key(2)]
|
||||
public string InstanceId { get; set; }
|
||||
public string InstanceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 服务端版本号
|
||||
/// </summary>
|
||||
[Key(3)]
|
||||
/// <summary>服务端版本号</summary>
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
|
||||
#endregion
|
||||
@@ -49,43 +38,26 @@ namespace SHH.Contracts
|
||||
/// 服务端所在的局域网 IP
|
||||
/// <para>客户端无法直接连接此IP(因为可能是内网),但运维人员需要知道</para>
|
||||
/// </summary>
|
||||
[Key(4)]
|
||||
public string ServerIp { get; set; }
|
||||
public string ServerIp { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// WebAPI 监听端口 (HTTP)
|
||||
/// <para>用于运维人员打开 Swagger 进行调试</para>
|
||||
/// </summary>
|
||||
[Key(5)]
|
||||
public int WebApiPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 视频流端口 (ZeroMQ Publisher/Push)
|
||||
/// </summary>
|
||||
[Key(6)]
|
||||
public int VideoPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 指令流端口 (ZeroMQ Response)
|
||||
/// </summary>
|
||||
[Key(7)]
|
||||
public int CmdPort { get; set; }
|
||||
/// <summary>Grpc通讯端口</summary>
|
||||
public int GrpcPort { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 3. 运行时状态 ---
|
||||
|
||||
/// <summary>
|
||||
/// 启动时间
|
||||
/// </summary>
|
||||
[Key(8)]
|
||||
/// <summary>启动时间</summary>
|
||||
public DateTime StartTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息 (可选)
|
||||
/// </summary>
|
||||
[Key(9)]
|
||||
public string Description { get; set; }
|
||||
/// <summary>描述信息 (可选)</summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
#endregion
|
||||
}
|
||||
23
SHH.Contracts.Grpc/Payloads/StatusEventPayload.cs
Normal file
23
SHH.Contracts.Grpc/Payloads/StatusEventPayload.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// [控制面] 设备状态变更通知包
|
||||
/// </summary>
|
||||
public class StatusEventPayload
|
||||
{
|
||||
/// <summary>摄像头ID</summary>
|
||||
public string CameraId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>IP地址</summary>
|
||||
public string IpAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>true: 上线/活跃, false: 离线/超时</summary>
|
||||
public bool IsOnline { get; set; }
|
||||
|
||||
/// <summary>变更原因 (e.g. "Ping Success", "Frame Timeout")</summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>时间戳</summary>
|
||||
public long Timestamp { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用指令执行结果 (Response)
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CommandResult
|
||||
{
|
||||
#region --- 0. 协议自描述 ---
|
||||
|
||||
[Key(0)]
|
||||
public string Protocol { get; set; } = "COMMAND_RESULT";
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 核心匹配信息 ---
|
||||
|
||||
/// <summary>
|
||||
/// 回执 ID (必须与请求包的 RequestId 一致)
|
||||
/// <para>客户端靠这个 ID 来找到对应的 await Task</para>
|
||||
/// </summary>
|
||||
[Key(1)]
|
||||
public string RequestId { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 执行结果 ---
|
||||
|
||||
/// <summary>
|
||||
/// 执行是否成功
|
||||
/// </summary>
|
||||
[Key(2)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结果消息 (成功提示或错误原因)
|
||||
/// </summary>
|
||||
[Key(3)]
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回的数据 (JSON 或 Base64 字符串)
|
||||
/// <para>示例: 截图的 Base64,或者查询到的设备列表 JSON</para>
|
||||
/// </summary>
|
||||
[Key(4)]
|
||||
public string Data { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 性能统计 ---
|
||||
|
||||
/// <summary>
|
||||
/// 全链路耗时 (毫秒)
|
||||
/// <para>从客户端发出指令,到收到服务端回执的总时长</para>
|
||||
/// <para>注意:该字段由客户端收到回执后自动计算填充,服务端不需要赋值</para>
|
||||
/// </summary>
|
||||
[Key(5)]
|
||||
public double ElapsedMilliseconds { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
[Key(6)]
|
||||
public long Timestamp { get; set;}
|
||||
|
||||
#region --- 快捷构造方法 ---
|
||||
|
||||
/// <summary>
|
||||
/// 快速创建一个成功的回执
|
||||
/// </summary>
|
||||
public static CommandResult Ok(string msg = "OK", string data = null)
|
||||
=> new CommandResult { Success = true, Message = msg, Data = data };
|
||||
|
||||
/// <summary>
|
||||
/// 快速创建一个失败的回执
|
||||
/// </summary>
|
||||
public static CommandResult Fail(string msg)
|
||||
=> new CommandResult { Success = false, Message = msg };
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using MessagePack;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// [控制面] 状态全量快照包
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class StatusBatchPayload
|
||||
{
|
||||
// [新增] 协议类型标识 (人工可读)
|
||||
// 建议值: "STATUS_BATCH" 或 "设备状态全量包"
|
||||
[Key(0)]
|
||||
public string Protocol { get; set; } = "STATUS_BATCH";
|
||||
|
||||
[Key(1)]
|
||||
public List<StatusEventPayload> Items { get; set; }
|
||||
= new List<StatusEventPayload>();
|
||||
|
||||
[Key(2)]
|
||||
public long Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [控制面] 设备状态变更通知包
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class StatusEventPayload
|
||||
{
|
||||
[Key(0)]
|
||||
public string CameraId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// true: 上线/活跃, false: 离线/超时
|
||||
/// </summary>
|
||||
[Key(1)]
|
||||
public bool IsOnline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变更原因 (e.g. "Ping Success", "Frame Timeout")
|
||||
/// </summary>
|
||||
[Key(2)]
|
||||
public string Reason { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public long Timestamp { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
172
SHH.MjpegPlayer/Bootstrapper.cs
Normal file
172
SHH.MjpegPlayer/Bootstrapper.cs
Normal file
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// 加载配置文件
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static MjpegConfig LoadConfig()
|
||||
{
|
||||
// [修复] 路径处理脆弱性:使用 BaseDirectory 拼接,避免相对路径替换的风险
|
||||
// 生产环境:强制使用绝对路径确保能找到配置文件
|
||||
if (!Debugger.IsAttached)
|
||||
{
|
||||
JsonConfigUris.MjpegConfig = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Path.GetFileName(JsonConfigUris.MjpegConfig));
|
||||
}
|
||||
|
||||
// 加载配置文件
|
||||
var cfg = JsonConfig.Load<MjpegConfig>(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
|
||||
|
||||
/// <summary>
|
||||
/// 检查 IP 与端口
|
||||
/// </summary>
|
||||
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<int> { 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
|
||||
|
||||
/// <summary>
|
||||
/// 内部 WCF 引擎初始化 (CoreWCF)
|
||||
/// </summary>
|
||||
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<IServiceBehavior, UseRequestHeadersForMetadataAddressBehavior>()
|
||||
.AddSingleton<MjpegImagesService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var wsBinding = new WSHttpBinding(SecurityMode.None);
|
||||
wsBinding.MaxReceivedMessageSize = cfg.SvrPushImageMaxRecMsgSize;
|
||||
|
||||
// Modified: [原因] 强制转换 IApplicationBuilder 修复 UseServiceModel 的二义性
|
||||
((IApplicationBuilder)app).UseServiceModel(serviceBuilder =>
|
||||
{
|
||||
serviceBuilder.AddService<MjpegImagesService>(opt =>
|
||||
{
|
||||
opt.BaseAddresses.Add(new Uri($"http://0.0.0.0:{cfg.WcfPushImagePort}"));
|
||||
})
|
||||
.AddServiceEndpoint<MjpegImagesService, IMjpegImagesService>(
|
||||
wsBinding,
|
||||
$"/{cfg.SvrNamePushImage}",
|
||||
new Uri($"http://0.0.0.0:{cfg.WcfPushImagePort}/{cfg.SvrNamePushImage}")
|
||||
);
|
||||
});
|
||||
|
||||
// 关闭元数据暴露增强安全性
|
||||
var meta = app.Services.GetRequiredService<ServiceMetadataBehavior>();
|
||||
meta.HttpGetEnabled = false;
|
||||
meta.HttpsGetEnabled = false;
|
||||
|
||||
Task.Run(() => app.Run());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExitApp
|
||||
|
||||
/// <summary>
|
||||
/// 应用程序退出
|
||||
/// </summary>
|
||||
/// <param name="exitMsg"></param>
|
||||
/// <param name="waitSeconds"></param>
|
||||
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
|
||||
}
|
||||
}
|
||||
120
SHH.MjpegPlayer/Core/Extensions/NetHttpExtension.cs
Normal file
120
SHH.MjpegPlayer/Core/Extensions/NetHttpExtension.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Ayay.SerilogLogs;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using System.Text;
|
||||
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// 扩展 HttpClient 的 PostJson 方法,用于发送 JSON 格式的数据
|
||||
/// </summary>
|
||||
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, 谨慎使用)
|
||||
|
||||
/// <summary>
|
||||
/// 发送 JSON 格式的 POST 请求 (同步)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送 JSON 格式的 POST 请求并反序列化 (同步)
|
||||
/// </summary>
|
||||
public static T? PostJson<T>(this object jsonData, string url, int timeout = 2000)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = PostJson(jsonData, url, timeout);
|
||||
return string.IsNullOrWhiteSpace(msg) ? default : JsonConvert.DeserializeObject<T>(msg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error(ex, "Post 同步请求并解析 JSON 异常: {Url}", url);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 异步方法 (推荐使用)
|
||||
|
||||
/// <summary>
|
||||
/// 发送 JSON 格式的 POST 请求 (异步)
|
||||
/// </summary>
|
||||
/// <param name="jsonData">要发送的对象</param>
|
||||
/// <param name="url">目标地址</param>
|
||||
/// <param name="timeout">超时(ms)</param>
|
||||
public static async Task<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送 JSON 格式的 POST 请求并反序列化 (异步)
|
||||
/// </summary>
|
||||
public static async Task<T?> PostJsonAsync<T>(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<T>(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error(ex, "Post 异步请求解析 JSON 失败: {Url}", url);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
190
SHH.MjpegPlayer/Core/Extensions/NetPortExtension.cs
Normal file
190
SHH.MjpegPlayer/Core/Extensions/NetPortExtension.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 网口占用检测
|
||||
/// </summary>
|
||||
public static class NetPortExtension
|
||||
{
|
||||
private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
|
||||
|
||||
#region IsServerPort
|
||||
|
||||
/// <summary>
|
||||
/// 是否端口
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static bool IsServerPort(this int value)
|
||||
{
|
||||
if (value > 0 && value < 65535)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsPortOccupied
|
||||
|
||||
/// <summary>
|
||||
/// 端口占用检测
|
||||
/// </summary>
|
||||
/// <param name="port"></param>
|
||||
/// <returns></returns>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 查询端口占用进程 Pid
|
||||
/// </summary>
|
||||
/// <param name="port"></param>
|
||||
/// <returns></returns>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 查询端口占用进程 Pid
|
||||
/// </summary>
|
||||
/// <param name="port"></param>
|
||||
/// <returns></returns>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 端口占用检测并杀掉进程
|
||||
/// </summary>
|
||||
/// <param name="port"></param>
|
||||
/// <returns>返回占用端口清理结果</returns>
|
||||
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
|
||||
}
|
||||
}
|
||||
197
SHH.MjpegPlayer/Core/Extensions/ProcessExtension.cs
Normal file
197
SHH.MjpegPlayer/Core/Extensions/ProcessExtension.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using Ayay.SerilogLogs;
|
||||
using Serilog;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// 进程扩展
|
||||
/// </summary>
|
||||
public static class ProcessExtension
|
||||
{
|
||||
private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
|
||||
|
||||
#region GetProcessName
|
||||
|
||||
/// <summary>
|
||||
/// 获取进程名称
|
||||
/// </summary>
|
||||
/// <param name="pid"></param>
|
||||
/// <returns></returns>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 杀掉进程
|
||||
/// </summary>
|
||||
/// <param name="pid"></param>
|
||||
/// <param name="procName"></param>
|
||||
/// <returns></returns>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 杀掉进程
|
||||
/// </summary>
|
||||
/// <param name="pid"></param>
|
||||
/// <param name="procName"></param>
|
||||
/// <returns></returns>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 开启进程
|
||||
/// </summary>
|
||||
/// <param name="procPath"></param>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 检测是否高权限等级
|
||||
/// </summary>
|
||||
/// <param name="proc"></param>
|
||||
/// <returns></returns>
|
||||
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
|
||||
}
|
||||
}
|
||||
61
SHH.MjpegPlayer/Core/ImageChannel.cs
Normal file
61
SHH.MjpegPlayer/Core/ImageChannel.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>图片通道</summary>
|
||||
public class ImageChannel
|
||||
{
|
||||
/// <summary>进程 ID</summary>
|
||||
public Int32 ProcId { get; set; }
|
||||
|
||||
/// <summary>设备 ID</summary>
|
||||
public Int64 DeviceId { get; set; }
|
||||
|
||||
/// <summary>设备 IP</summary>
|
||||
public string IpAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>名称</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>类型</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>图像宽度</summary>
|
||||
public int ImageWidth { get; set; }
|
||||
|
||||
/// <summary>图像高度</summary>
|
||||
public int ImageHeight { get; set; }
|
||||
|
||||
/// <summary>更新时间</summary>
|
||||
public DateTime UpdateTime { get; set; }
|
||||
|
||||
/// <summary>是否正在播放</summary>
|
||||
public bool IsPlaying { get; set; }
|
||||
|
||||
/// <summary>是否需要推流到 Rtmp 服务器</summary>
|
||||
public bool UseRtmp { get; set; } = true;
|
||||
|
||||
#region RtmpUri
|
||||
|
||||
private string _rtmpUri = string.Empty;
|
||||
|
||||
/// <summary>Rtmp 推流地址</summary>
|
||||
public string RtmpUri
|
||||
{
|
||||
get => _rtmpUri;
|
||||
set
|
||||
{
|
||||
if (_rtmpUri == value)
|
||||
return;
|
||||
_rtmpUri = value;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TestUri
|
||||
|
||||
/// <summary>测试地址</summary>
|
||||
public string TestUri => $"?id={DeviceId}&typeCode={Type}";
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
72
SHH.MjpegPlayer/Core/ImageChannels.cs
Normal file
72
SHH.MjpegPlayer/Core/ImageChannels.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Core.WcfProtocol;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>图片通道集合</summary>
|
||||
public class ImageChannels
|
||||
{
|
||||
#region Channels
|
||||
|
||||
/// <summary>
|
||||
/// 通道信息 (线程安全版本)
|
||||
/// </summary>
|
||||
// [修复] 使用 ConcurrentDictionary 替代 Dictionary,防止多线程读写(如推流和接收图片同时进行)时崩溃
|
||||
public ConcurrentDictionary<string, ImageChannel> Channels { get; set; }
|
||||
= new ConcurrentDictionary<string, ImageChannel>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Do
|
||||
|
||||
/// <summary>
|
||||
/// 处置图片
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
/// <param name="key"></param>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 获取通道信息
|
||||
/// </summary>
|
||||
/// <param name="deviceId"></param>
|
||||
/// <param name="aiTypeCode"></param>
|
||||
/// <returns></returns>
|
||||
public ImageChannel? Get(string deviceId, string aiTypeCode)
|
||||
{
|
||||
string key = $"{deviceId}#{aiTypeCode}";
|
||||
|
||||
// [修复] ConcurrentDictionary 读取原本就是线程安全的
|
||||
if (Channels.TryGetValue(key, out var val))
|
||||
{
|
||||
return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
94
SHH.MjpegPlayer/Core/JsonConfig.cs
Normal file
94
SHH.MjpegPlayer/Core/JsonConfig.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// Json 配置文件
|
||||
/// </summary>
|
||||
public class JsonConfig
|
||||
{
|
||||
#region Load
|
||||
|
||||
/// <summary>
|
||||
/// 加载配置
|
||||
/// </summary>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
/// <param name="path"></param>
|
||||
/// <returns></returns>
|
||||
public static T? Load<T>(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<T>(data);
|
||||
//Logs.LogInformation<JsonConfig>(EIdFiles.LoadSucceed,
|
||||
// $"配置{EIdFiles.LoadSucceed.GetDescription()}, Path:{path} 类型:{typeof(T).FullName}.");
|
||||
return obj;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Logs.LogWarning<JsonConfig>(EIdFiles.LoadFailed,
|
||||
// $"配置{EIdFiles.LoadSucceed.GetDescription()}, Path:{path} 类型:{typeof(T).FullName}.", ex.Message, ex.StackTrace);
|
||||
return default(T);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Save
|
||||
|
||||
/// <summary>
|
||||
/// 保存配置
|
||||
/// </summary>
|
||||
/// <param name="obj"></param>
|
||||
/// <param name="path"></param>
|
||||
/// <param name="caption"></param>
|
||||
/// <returns></returns>
|
||||
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<JsonConfig>(EIdFiles.SaveSucceed,
|
||||
// $"配置{EIdFiles.SaveSucceed.GetDescription()}, Path:{path}\r\n\t\t\tCaption:{caption} 类型:{obj.GetType().FullName}.");
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Logs.LogInformation<JsonConfig>(EIdFiles.SaveFailed,
|
||||
// $"配置{EIdFiles.SaveFailed.GetDescription()}, Path:{path}\r\n\t\t\tCaption:{caption} 类型:{obj.GetType().FullName}.", ex.Message, ex.StackTrace);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
17
SHH.MjpegPlayer/Core/Models/CfgRtmpReply.cs
Normal file
17
SHH.MjpegPlayer/Core/Models/CfgRtmpReply.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>RTMP 配置响应类</summary>
|
||||
public class CfgRtmpReply
|
||||
{
|
||||
/// <summary>响应消息</summary>
|
||||
public string msg { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>响应状态码</summary>
|
||||
public int code { get; set; }
|
||||
|
||||
/// <summary>RTMP 推流地址列表</summary>
|
||||
public RtmpVo[]? rtmpVoList { get; set; }
|
||||
|
||||
/// <summary>是否成功(状态码为 200 时返回 true)</summary>
|
||||
public bool IsSuccess => code == 200;
|
||||
}
|
||||
35
SHH.MjpegPlayer/Core/Models/EIdSys.cs
Normal file
35
SHH.MjpegPlayer/Core/Models/EIdSys.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
public enum EIdSys
|
||||
{
|
||||
/// <summary>根据PID杀掉进程成功</summary>
|
||||
[Description("根据PID杀掉进程成功")]
|
||||
KillProcByIdSucceed = 1000101,
|
||||
|
||||
/// <summary>按秒统计汇总</summary>
|
||||
[Description("按秒统计汇总")]
|
||||
TotalBySecond = 100701,
|
||||
|
||||
/// <summary>按分钟统计汇总</summary>
|
||||
[Description("按分钟统计汇总")]
|
||||
TotalByMinute = 100702,
|
||||
|
||||
/// <summary>按小时统计汇总</summary>
|
||||
[Description("按小时统计汇总")]
|
||||
TotalByHour = 100703,
|
||||
|
||||
/// <summary>查询进程名出错</summary>
|
||||
[Description("查询进程名出错")]
|
||||
SearchProcNameError = 1000901,
|
||||
|
||||
/// <summary>根据PID杀掉进程出错</summary>
|
||||
[Description("根据PID杀掉进程出错")]
|
||||
KillProcByIdError = 1000902,
|
||||
|
||||
/// <summary>启动进程出错</summary>
|
||||
[Description("启动进程出错")]
|
||||
StartProcessError = 1000903,
|
||||
}
|
||||
}
|
||||
18
SHH.MjpegPlayer/Core/Models/JsonConfigUris.cs
Normal file
18
SHH.MjpegPlayer/Core/Models/JsonConfigUris.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
43
SHH.MjpegPlayer/Core/Models/MjpegConfig.cs
Normal file
43
SHH.MjpegPlayer/Core/Models/MjpegConfig.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// Mjpeg 配置
|
||||
/// </summary>
|
||||
public class MjpegConfig
|
||||
{
|
||||
/// <summary>Mjpeg 服务 IP 地址</summary>
|
||||
public string SvrMjpegIp
|
||||
= "0.0.0.0";
|
||||
|
||||
/// <summary>Mjpeg 服务端口开始</summary>
|
||||
public int SvrMjpegPortBegin
|
||||
= 25031;
|
||||
|
||||
/// <summary>Mjpeg 服务端口结束</summary>
|
||||
public int SvrMjpegPortEnd
|
||||
= 25300;
|
||||
|
||||
/// <summary>帧间隔, 单位毫秒 (值为 125, 每秒 8 帧)</summary>
|
||||
public int FrameInterval { get; set; }
|
||||
= 125;
|
||||
|
||||
/// <summary>Mjpeg Wcf 接收图片接口</summary>
|
||||
public int WcfPushImagePort
|
||||
= 25030;
|
||||
|
||||
/// <summary>接收图片的服务器名称</summary>
|
||||
public string SvrNamePushImage { get; set; }
|
||||
= "ImageService.svc";
|
||||
|
||||
/// <summary>最大接收数据大小</summary>
|
||||
public int SvrPushImageMaxRecMsgSize { get; set; }
|
||||
= 2000 * 1024 * 1024;
|
||||
|
||||
/// <summary>Rtmp 服务地址</summary>
|
||||
public string RtmpServerDjhUri { get; set; }
|
||||
= "http://172.16.41.108:8889/intellect/nvr/getRtmp";
|
||||
|
||||
/// <summary>是否使用 Rtmp 服务</summary>
|
||||
public bool UseRtmpServer { get; set; }
|
||||
= false;
|
||||
}
|
||||
17
SHH.MjpegPlayer/Core/Models/RtmpVo.cs
Normal file
17
SHH.MjpegPlayer/Core/Models/RtmpVo.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>RTMP 推流对象类</summary>
|
||||
public class RtmpVo
|
||||
{
|
||||
/// <summary>算法代码</summary>
|
||||
public string algCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>设备ID</summary>
|
||||
public string deviceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>设备IP地址</summary>
|
||||
public string deviceIp { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>RTMP 推流地址</summary>
|
||||
public string rtmp { get; set; } = string.Empty;
|
||||
}
|
||||
100
SHH.MjpegPlayer/Core/Models/SessionInfo.cs
Normal file
100
SHH.MjpegPlayer/Core/Models/SessionInfo.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// 会话信息
|
||||
/// </summary>
|
||||
public class SessionInfo
|
||||
{
|
||||
#region Key
|
||||
|
||||
/// <summary>流标识</summary>
|
||||
public string? Key => $"{DeviceId}#{TypeCode}";
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeviceId
|
||||
|
||||
/// <summary>设备类型</summary>
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region TypeCode
|
||||
|
||||
/// <summary>类型编码</summary>
|
||||
public string? TypeCode { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region ClientIp
|
||||
|
||||
/// <summary>客户端 IP</summary>
|
||||
public string? ClientIp { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region ClientPort
|
||||
|
||||
/// <summary>客户端端口</summary>
|
||||
public int ClientPort { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Message
|
||||
|
||||
/// <summary>消息</summary>
|
||||
public string? Message { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region AcceptTime
|
||||
|
||||
/// <summary>接入时间</summary>
|
||||
public DateTime AcceptTime { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Counter
|
||||
|
||||
/// <summary>计数器</summary>
|
||||
public SumByTime? Counter { get; init; }
|
||||
|
||||
#endregion
|
||||
|
||||
// =======================================================
|
||||
// [新增] 专门给诊断大屏用的属性,前端可直接读取数值
|
||||
// =======================================================
|
||||
|
||||
/// <summary>接收帧率 (源头健康度)</summary>
|
||||
public int RecvFps
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Counter == null || Counter.TotalSecond == null) return 0;
|
||||
|
||||
// 从字典中安全获取 "接收帧数"
|
||||
if (Counter.TotalSecond.TryGetValue("接收帧数", out uint val))
|
||||
{
|
||||
return (int)val;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>播放/发送帧率 (客户端健康度)</summary>
|
||||
public int PlayFps
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Counter == null || Counter.TotalSecond == null) return 0;
|
||||
|
||||
// 从字典中安全获取 "播放帧数"
|
||||
if (Counter.TotalSecond.TryGetValue("播放帧数", out uint val))
|
||||
{
|
||||
return (int)val;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
190
SHH.MjpegPlayer/Core/Models/SumByTime.cs
Normal file
190
SHH.MjpegPlayer/Core/Models/SumByTime.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using System.Text;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// 按时间统计
|
||||
/// </summary>
|
||||
public class SumByTime
|
||||
{
|
||||
#region Defines
|
||||
|
||||
/// <summary>最近刷新在哪一秒</summary>
|
||||
private int LastRefreshSecond = DateTime.Now.Second;
|
||||
|
||||
/// <summary>最近刷新在哪一分钟</summary>
|
||||
private int LastRefreshMinute = DateTime.Now.Minute;
|
||||
|
||||
/// <summary>最近刷新在哪一小时</summary>
|
||||
private int LastRefreshHour = DateTime.Now.Minute;
|
||||
|
||||
/// <summary>秒统计</summary>
|
||||
private Dictionary<string, uint> _second
|
||||
= new Dictionary<string, uint>();
|
||||
|
||||
/// <summary>分钟统计</summary>
|
||||
private Dictionary<string, uint> _minute
|
||||
= new Dictionary<string, uint>();
|
||||
|
||||
/// <summary>小时统计</summary>
|
||||
private Dictionary<string, uint> _hour
|
||||
= new Dictionary<string, uint>();
|
||||
|
||||
/// <summary>累计统计</summary>
|
||||
public Dictionary<string, ulong> All { get; init; }
|
||||
= new Dictionary<string, ulong>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region TotalSecond
|
||||
|
||||
/// <summary>秒统计</summary>
|
||||
public Dictionary<string, uint> TotalSecond { get; init; }
|
||||
= new Dictionary<string, uint>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region TotalMinute
|
||||
|
||||
/// <summary>分统计</summary>
|
||||
public Dictionary<string, uint> TotalMinute { get; init; }
|
||||
= new Dictionary<string, uint>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region TotalHour
|
||||
|
||||
/// <summary>小时统计</summary>
|
||||
public Dictionary<string, uint> TotalHour { get; init; }
|
||||
= new Dictionary<string, uint>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Refresh
|
||||
|
||||
/// <summary>
|
||||
/// 刷新方法调用次数
|
||||
/// </summary>
|
||||
/// <param name="logger"></param>
|
||||
/// <param name="methodName"></param>
|
||||
/// <param name="count"></param>
|
||||
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<SumByTime>(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<SumByTime>(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<SumByTime>(EIdSys.TotalByHour, logMsg);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 数值更新
|
||||
|
||||
_second[methodName] += count;
|
||||
_minute[methodName] += count;
|
||||
_hour[methodName] += count;
|
||||
All[methodName] += count;
|
||||
|
||||
#endregion
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Logs.LogWarning<SumByTime>(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
68
SHH.MjpegPlayer/Core/PrismMsg.cs
Normal file
68
SHH.MjpegPlayer/Core/PrismMsg.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Prism.Events;
|
||||
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>Prism 消息框架</summary>
|
||||
public class PrismMsg<T>
|
||||
{
|
||||
#region Defines
|
||||
|
||||
public IEventAggregator _ea;
|
||||
|
||||
private static PrismMsg<T>? _instance = null;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
|
||||
/// <summary>构造函数</summary>
|
||||
private PrismMsg()
|
||||
{
|
||||
_ea = new EventAggregator();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Instance
|
||||
|
||||
/// <summary>获取实例信息</summary>
|
||||
public static PrismMsg<T> Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
_instance = new PrismMsg<T>();
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Publish
|
||||
|
||||
/// <summary>发送消息</summary>
|
||||
public static void Publish(T msg)
|
||||
{
|
||||
if (Instance == null)
|
||||
return;
|
||||
|
||||
dynamic? data = msg;
|
||||
Instance._ea.GetEvent<PubSubEvent<T>>().Publish(data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Subscribe
|
||||
|
||||
/// <summary>订阅消息</summary>
|
||||
public static void Subscribe(Action<T> method)
|
||||
{
|
||||
if (Instance == null || Instance._ea == null)
|
||||
return;
|
||||
|
||||
Instance._ea.GetEvent<PubSubEvent<T>>().Subscribe(method);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
101
SHH.MjpegPlayer/Core/Watchs/MemoryWatchdog.cs
Normal file
101
SHH.MjpegPlayer/Core/Watchs/MemoryWatchdog.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using Ayay.SerilogLogs;
|
||||
using Serilog;
|
||||
using System.Diagnostics;
|
||||
using System.Timers;
|
||||
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// 内存监控
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 启动内存监控
|
||||
/// </summary>
|
||||
/// <param name="intervalSeconds">检查间隔(秒),默认60秒</param>
|
||||
/// <param name="limitMB">内存阈值(MB),超过此值自动退出,默认800MB</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
SHH.MjpegPlayer/GrpcImpls/Core/ConcurrentHashSet.cs
Normal file
22
SHH.MjpegPlayer/GrpcImpls/Core/ConcurrentHashSet.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// 辅助类:线程安全集合
|
||||
/// </summary>
|
||||
public class ConcurrentHashSet<T> : IEnumerable<T>
|
||||
{
|
||||
private readonly ConcurrentDictionary<T, byte> _dict = new ConcurrentDictionary<T, byte>();
|
||||
|
||||
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<T> GetEnumerator() => _dict.Keys.GetEnumerator();
|
||||
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
106
SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceConfigHandler.cs
Normal file
106
SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceConfigHandler.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// 设备配置同步处理器 (原 ConfigSyncManager 瘦身版)
|
||||
/// 职责:仅负责确保远程分析节点(Instance)的摄像头配置与本地数据库一致。
|
||||
/// 逻辑:通过 5 秒初始化冷却期避开抖动,并利用配置快照对比实现增量同步。
|
||||
/// </summary>
|
||||
public class DeviceConfigHandler
|
||||
{
|
||||
#region 单例与核心存储字段
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置处理器的全局单例实例
|
||||
/// </summary>
|
||||
public static DeviceConfigHandler Instance { get; } = new DeviceConfigHandler();
|
||||
|
||||
/// <summary>
|
||||
/// 活跃服务实例 ID 集合 (InstanceId)
|
||||
/// 用于记录当前所有已建立 gRpc 长连接的远程节点
|
||||
/// </summary>
|
||||
private readonly ConcurrentHashSet<string> _activeServiceIds = new ConcurrentHashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 配置快照缓存:用于防止重复下发相同的配置
|
||||
/// Key 格式: "InstanceId_CameraId"
|
||||
/// Value: 该摄像头配置的 JSON 字符串快照
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, string> _lastSentConfigCache = new ConcurrentDictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// 后台监控任务的任务取消令牌源
|
||||
/// </summary>
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化完成时间戳:用于 5 秒冷却期判定
|
||||
/// 防止在服务刚启动或节点刚连接时,由于数据库加载延迟导致误判设备被移除
|
||||
/// </summary>
|
||||
private DateTime _initCompleteTime = DateTime.MaxValue;
|
||||
|
||||
#endregion
|
||||
|
||||
#region 构造函数与初始化
|
||||
|
||||
/// <summary>
|
||||
/// 私有构造函数:订阅消息总线并启动监控任务
|
||||
/// </summary>
|
||||
private DeviceConfigHandler()
|
||||
{
|
||||
// 订阅总线:仅关注节点注册事件,以此作为触发初始化全量同步的开关
|
||||
MessageBus.Instance.OnServerRegistered += async (payload) =>
|
||||
{
|
||||
await HandleServiceOnlineAsync(payload.InstanceId);
|
||||
};
|
||||
|
||||
// 启动后台轮询监控任务 (检测 Add/Update/Remove)
|
||||
StartMonitorTask();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 核心业务处理 (节点上线)
|
||||
|
||||
/// <summary>
|
||||
/// 处理新节点上线:执行全量同步
|
||||
/// </summary>
|
||||
/// <param name="instanceId">远程服务实例唯一标识</param>
|
||||
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 后台监控任务 (增量同步)
|
||||
|
||||
/// <summary>
|
||||
/// 启动后台增量监控任务
|
||||
/// </summary>
|
||||
private void StartMonitorTask()
|
||||
{
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
48
SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceStatusHandler.cs
Normal file
48
SHH.MjpegPlayer/GrpcImpls/Handlers/DeviceStatusHandler.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// 设备状态处理器
|
||||
/// 职责:监听消息总线发出的状态主题事件,负责将远程节点上报的相机在线/离线状态实时同步至本地管理中心。
|
||||
/// 架构说明:此类实现了业务逻辑的彻底解耦,不涉及 gRpc 通讯细节,也不涉及复杂的配置下发逻辑。
|
||||
/// </summary>
|
||||
public class DeviceStatusHandler
|
||||
{
|
||||
#region 单例模式
|
||||
|
||||
/// <summary>
|
||||
/// 获取设备状态处理器的全局单例实例。
|
||||
/// 由 GrpcServerManager 在系统启动时显式调用以完成初始化。
|
||||
/// </summary>
|
||||
public static DeviceStatusHandler Instance { get; } = new DeviceStatusHandler();
|
||||
|
||||
/// <summary>
|
||||
/// 私有构造函数:在此处完成对消息总线事件的订阅。
|
||||
/// </summary>
|
||||
private DeviceStatusHandler()
|
||||
{
|
||||
// 订阅 MessageBus 的状态报告主题,当总线收到状态更新包时自动触发 SyncToLocal
|
||||
MessageBus.Instance.OnDeviceStatusReport += SyncToLocal;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 核心业务逻辑
|
||||
|
||||
/// <summary>
|
||||
/// 执行状态同步:将收到的 Payload 数据精确映射回本地 SDK 管理的摄像头集合中。
|
||||
/// </summary>
|
||||
/// <param name="items">包含 CameraId 和在线状态的业务载荷列表</param>
|
||||
private void SyncToLocal(List<StatusEventPayload> items)
|
||||
{
|
||||
// 1. 基础校验:若无数据则不执行后续逻辑
|
||||
if (items == null || items.Count == 0) return;
|
||||
|
||||
// 2. 性能优化:将上报列表转换为字典,利用哈希查找提升大数据量下的匹配效率 (Key: CameraId 字符串)
|
||||
var stateMap = items.ToDictionary(k => k.CameraId, v => v);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
156
SHH.MjpegPlayer/GrpcImpls/Handlers/GatewayService.cs
Normal file
156
SHH.MjpegPlayer/GrpcImpls/Handlers/GatewayService.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using Grpc.Core;
|
||||
using SHH.Contracts;
|
||||
using SHH.Contracts.Grpc;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// gRpc 网关服务
|
||||
/// 职责:作为服务端通讯入口,负责接收客户端(分析节点)的所有 gRpc 请求,将其转译为内部业务载荷,
|
||||
/// 并通过消息总线 MessageBus 分发至对应的业务处理器。
|
||||
/// </summary>
|
||||
public class GatewayService : GatewayProvider.GatewayProviderBase
|
||||
{
|
||||
#region 1. 逻辑身份注册 (Unary 调用)
|
||||
|
||||
/// <summary>
|
||||
/// 处理分析节点的注册请求
|
||||
/// </summary>
|
||||
/// <param name="request">包含节点实例 ID 和服务器 IP 的请求对象</param>
|
||||
/// <param name="context">gRpc 调用上下文</param>
|
||||
/// <returns>操作成功响应</returns>
|
||||
public override Task<GenericResponse> 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)
|
||||
|
||||
/// <summary>
|
||||
/// 建立并维持一个从服务器向客户端单向推送指令的长连接通道
|
||||
/// </summary>
|
||||
/// <param name="request">连接请求(包含 InstanceId)</param>
|
||||
/// <param name="responseStream">响应流,用于后续异步推送指令</param>
|
||||
/// <param name="context">gRpc 调用上下文</param>
|
||||
/// <returns>异步任务</returns>
|
||||
public override async Task OpenCommandChannel(CommandStreamRequest request, IServerStreamWriter<CommandPayloadProto> 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 调用)
|
||||
|
||||
/// <summary>
|
||||
/// 接收来自分析节点的相机在线/离线状态批量上报
|
||||
/// </summary>
|
||||
/// <param name="request">包含多个设备状态项的请求对象</param>
|
||||
/// <param name="context">gRpc 调用上下文</param>
|
||||
/// <returns>操作成功响应</returns>
|
||||
public override Task<GenericResponse> 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)
|
||||
|
||||
/// <summary>
|
||||
/// 接收分析节点持续推送的视频帧数据流
|
||||
/// </summary>
|
||||
/// <param name="requestStream">客户端异步流读取器</param>
|
||||
/// <param name="context">gRpc 调用上下文</param>
|
||||
/// <returns>流关闭后的最终响应</returns>
|
||||
public override async Task<GenericResponse> UploadVideoStream(IAsyncStreamReader<VideoFrameRequest> 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
|
||||
}
|
||||
}
|
||||
108
SHH.MjpegPlayer/GrpcImpls/Handlers/GrpcSessionManager.cs
Normal file
108
SHH.MjpegPlayer/GrpcImpls/Handlers/GrpcSessionManager.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Grpc.Core;
|
||||
using SHH.Contracts.Grpc;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// gRpc 会话管理器
|
||||
/// 职责:专门负责维护、检索和清理所有远程客户端(分析节点)的 gRpc 指令下发物理通道 (Stream)。
|
||||
/// 它是连接“业务逻辑”与“物理传输”的桥梁,确保指令能准确投递到对应的连接流中。
|
||||
/// </summary>
|
||||
public class GrpcSessionManager
|
||||
{
|
||||
#region 单例模式
|
||||
|
||||
/// <summary>
|
||||
/// 获取会话管理器的全局单例实例。
|
||||
/// </summary>
|
||||
public static GrpcSessionManager Instance { get; } = new GrpcSessionManager();
|
||||
|
||||
/// <summary>
|
||||
/// 私有构造函数,防止外部实例化。
|
||||
/// </summary>
|
||||
private GrpcSessionManager() { }
|
||||
|
||||
#endregion
|
||||
|
||||
#region 内部存储
|
||||
|
||||
/// <summary>
|
||||
/// 物理流存储字典
|
||||
/// Key: 远程服务实例唯一 ID (InstanceId)
|
||||
/// Value: gRpc 双向流或服务端推送流的写入器句柄 (IServerStreamWriter)
|
||||
/// 使用 ConcurrentDictionary 确保在多客户端并发连接/断开时的线程安全性。
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, IServerStreamWriter<CommandPayloadProto>> _sessionStreams
|
||||
= new ConcurrentDictionary<string, IServerStreamWriter<CommandPayloadProto>>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region 公共管理接口
|
||||
|
||||
/// <summary>
|
||||
/// 注册/更新物理物理通道。
|
||||
/// 当客户端调用 OpenCommandChannel 并成功建立 Server Streaming 连接时,由 GatewayService 调用此方法。
|
||||
/// </summary>
|
||||
/// <param name="instanceId">客户端实例唯一标识</param>
|
||||
/// <param name="responseStream">该客户端对应的 gRpc 响应流句柄</param>
|
||||
public void RegisterSession(string instanceId, IServerStreamWriter<CommandPayloadProto> responseStream)
|
||||
{
|
||||
// 1. 参数校验:无效 ID 不予处理
|
||||
if (string.IsNullOrEmpty(instanceId)) return;
|
||||
|
||||
// 2. 登记或覆盖物理流:
|
||||
// 如果客户端异常断开后迅速重连,此处会覆盖旧的流句柄,确保指令始终通过最新的管道下发。
|
||||
_sessionStreams[instanceId] = responseStream;
|
||||
|
||||
// 3. 记录日志:便于运维监控连接状态
|
||||
Console.WriteLine($"[Session] 物理通道就绪通知 -> 节点 ID: {instanceId}, 当前在线总数: {_sessionStreams.Count}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除物理通道。
|
||||
/// 当 gRpc 连接由于网络波动、客户端崩溃或主动关闭而断开时,由 GatewayService 的 finally 块调用。
|
||||
/// </summary>
|
||||
/// <param name="instanceId">要注销的客户端实例 ID</param>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检索目标节点的物理流句柄。
|
||||
/// 供 MessageBus 使用,它是指令下发前定位物理路径的关键步骤。
|
||||
/// </summary>
|
||||
/// <param name="instanceId">目标节点的唯一 ID</param>
|
||||
/// <returns>返回对应的 IServerStreamWriter 实例;若节点不在线则返回 null</returns>
|
||||
public IServerStreamWriter<CommandPayloadProto> GetSession(string instanceId)
|
||||
{
|
||||
// 1. 参数校验
|
||||
if (string.IsNullOrEmpty(instanceId)) return null;
|
||||
|
||||
// 2. 尝试从缓存字典中获取流句柄
|
||||
_sessionStreams.TryGetValue(instanceId, out var stream);
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定节点是否处于物理连接状态。
|
||||
/// </summary>
|
||||
/// <param name="instanceId">实例 ID</param>
|
||||
/// <returns>True 表示物理通道已建立</returns>
|
||||
public bool IsSessionActive(string instanceId)
|
||||
{
|
||||
return !string.IsNullOrEmpty(instanceId) && _sessionStreams.ContainsKey(instanceId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
137
SHH.MjpegPlayer/GrpcImpls/Handlers/MessageBus.cs
Normal file
137
SHH.MjpegPlayer/GrpcImpls/Handlers/MessageBus.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using SHH.Contracts;
|
||||
using SHH.Contracts.Grpc;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息总线中心 (纯 gRpc 架构)
|
||||
/// 职责:解耦 gRpc 接收端与业务处理层,提供基于主题(Topic)的事件发布与统一的指令下发路由。
|
||||
/// </summary>
|
||||
public class MessageBus : IDisposable
|
||||
{
|
||||
#region 单例模式
|
||||
|
||||
/// <summary>
|
||||
/// 消息总线全局唯一实例
|
||||
/// </summary>
|
||||
public static MessageBus Instance { get; } = new MessageBus();
|
||||
|
||||
/// <summary>
|
||||
/// 私有构造函数
|
||||
/// </summary>
|
||||
private MessageBus() { }
|
||||
|
||||
#endregion
|
||||
|
||||
#region 业务事件订阅主题 (Topics)
|
||||
|
||||
/// <summary>
|
||||
/// 1. 注册主题:当远程分析节点成功建立逻辑连接时触发。
|
||||
/// 订阅者通常为 DeviceConfigHandler,用于启动初始化配置同步。
|
||||
/// </summary>
|
||||
public event Action<RegisterPayload>? OnServerRegistered;
|
||||
|
||||
/// <summary>
|
||||
/// 2. 状态主题:当收到远程节点批量上报的设备在线/离线状态时触发。
|
||||
/// 订阅者通常为 DeviceStatusHandler,用于更新 UI 状态。
|
||||
/// </summary>
|
||||
public event Action<List<StatusEventPayload>>? OnDeviceStatusReport;
|
||||
|
||||
#endregion
|
||||
|
||||
#region 事件发布接口 (供 GatewayService 接收端调用)
|
||||
|
||||
/// <summary>
|
||||
/// 发布节点注册事件:将 gRpc 接收到的原始注册请求推送到业务层
|
||||
/// </summary>
|
||||
/// <param name="p">注册载荷信息</param>
|
||||
public void RaiseServerRegistered(RegisterPayload p)
|
||||
{
|
||||
if (p == null) return;
|
||||
|
||||
// 调试日志:跟踪节点上线流程
|
||||
Debug.WriteLine($"[Bus] 发布注册事件: 节点ID = {p.InstanceId}");
|
||||
|
||||
// 执行所有已订阅该主题的业务逻辑
|
||||
OnServerRegistered?.Invoke(p);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发布状态报告事件:将 gRpc 接收到的设备状态批量推送到业务层
|
||||
/// </summary>
|
||||
/// <param name="items">设备状态变更列表</param>
|
||||
public void RaiseDeviceStatusReport(List<StatusEventPayload> items)
|
||||
{
|
||||
if (items == null || items.Count == 0) return;
|
||||
|
||||
// 执行所有已订阅状态同步的业务逻辑
|
||||
OnDeviceStatusReport?.Invoke(items);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 指令下发接口 (供各 Handler 业务层调用)
|
||||
|
||||
/// <summary>
|
||||
/// 统一指令下发路由:自动定位目标节点的物理 gRpc 流并推送指令载荷
|
||||
/// </summary>
|
||||
/// <param name="instanceId">目标分析节点的唯一识别码</param>
|
||||
/// <param name="payload">要发送的业务指令负载</param>
|
||||
/// <returns>异步任务</returns>
|
||||
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 资源释放
|
||||
|
||||
/// <summary>
|
||||
/// 释放总线资源
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// 清理所有事件订阅,防止内存泄漏
|
||||
OnServerRegistered = null;
|
||||
OnDeviceStatusReport = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using SHH.Contracts;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// AI 视频流监控控制器
|
||||
/// 职责:接收 gRpc 转换后的 Payload -> 业务转换 -> 分发 UI/AI
|
||||
/// </summary>
|
||||
public class ImageMonitorController
|
||||
{
|
||||
public static ImageMonitorController Instance { get; } = new ImageMonitorController();
|
||||
|
||||
private ImageMonitorController() { }
|
||||
|
||||
/// <summary>
|
||||
/// 统一接收入口:由 GatewayProviderImpl.UploadVideoStream 调用
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Core.WcfProtocol;
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// 图像载荷转换器 (原 PayloadConverter)
|
||||
/// 职责:抹平传输契约与业务契约之间的差异。
|
||||
/// </summary>
|
||||
public static class ImagePayloadConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// 将视频负载转换为 XWcf 协议并分发至会话池
|
||||
/// </summary>
|
||||
/// <param name="payload">VideoPayload 纯净版契约对象</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 辅助方法:Unix 毫秒时间戳转 DateTime
|
||||
/// </summary>
|
||||
private static DateTime UnixMillisecondsToDateTime(long timestamp)
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds(timestamp).LocalDateTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
62
SHH.MjpegPlayer/GrpcServerManager.cs
Normal file
62
SHH.MjpegPlayer/GrpcServerManager.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Grpc.Core;
|
||||
using SHH.Contracts.Grpc;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// gRpc 服务宿主管理器
|
||||
/// 职责:初始化业务处理器、配置并启动 gRpc 监听服务。
|
||||
/// </summary>
|
||||
public static class GrpcServerManager
|
||||
{
|
||||
private static Server? _server;
|
||||
|
||||
/// <summary>
|
||||
/// 启动 gRpc 服务并初始化业务 Handler
|
||||
/// </summary>
|
||||
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}");
|
||||
// 此处建议记录到本地错误日志文件
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止服务并释放资源
|
||||
/// </summary>
|
||||
public static void Stop()
|
||||
{
|
||||
if (_server != null)
|
||||
{
|
||||
_server.ShutdownAsync().Wait();
|
||||
Console.WriteLine("[System] gRpc 服务已停止。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
SHH.MjpegPlayer/MJpegPlayer.Solution.sln
Normal file
37
SHH.MjpegPlayer/MJpegPlayer.Solution.sln
Normal file
@@ -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
|
||||
26
SHH.MjpegPlayer/MjpegStatics.cs
Normal file
26
SHH.MjpegPlayer/MjpegStatics.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// 静态参数集合
|
||||
/// </summary>
|
||||
public class MjpegStatics
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置项
|
||||
/// </summary>
|
||||
public static MjpegConfig Cfg { get; set; }
|
||||
= new MjpegConfig();
|
||||
|
||||
/// <summary>
|
||||
/// 会话集合
|
||||
/// </summary>
|
||||
public static MjpegSessions Sessions { get; private set; }
|
||||
= new MjpegSessions();
|
||||
|
||||
/// <summary>
|
||||
/// 图片通道集合
|
||||
/// </summary>
|
||||
public static ImageChannels ImageChannels { get; private set; }
|
||||
= new ImageChannels();
|
||||
}
|
||||
}
|
||||
107
SHH.MjpegPlayer/Program.cs
Normal file
107
SHH.MjpegPlayer/Program.cs
Normal file
@@ -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<DeviceConfigHandler>();
|
||||
builder.Services.AddSingleton<DeviceStatusHandler>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// 3. 映射服务路(将逻辑与端口绑定)
|
||||
app.MapGrpcService<GatewayService>();
|
||||
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 开启服务监听
|
||||
/// </summary>
|
||||
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<Program>(ex.Message, ex.StackTrace);
|
||||
// 退出应用
|
||||
Bootstrapper.ExitApp("应用程序崩溃.");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region StartWcfServer
|
||||
|
||||
/// <summary>
|
||||
/// 开启 Wcf 服务
|
||||
/// </summary>
|
||||
private static void StartWcfServer()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfg = MjpegStatics.Cfg;
|
||||
Bootstrapper.StartWcfEngine(cfg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Fatal(ex, "应用程序崩溃.");
|
||||
|
||||
// 退出应用
|
||||
Bootstrapper.ExitApp("应用程序崩溃.");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
378
SHH.MjpegPlayer/Protocols/Base2Reply.cs
Normal file
378
SHH.MjpegPlayer/Protocols/Base2Reply.cs
Normal file
@@ -0,0 +1,378 @@
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace Core.Protocol
|
||||
{
|
||||
/// <summary>
|
||||
/// 基础响应分页
|
||||
/// </summary>
|
||||
public class BaseReplyPagination
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前页
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public int Current_Page { get; set; }
|
||||
= 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页数量
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public int Page_Size { get; set; }
|
||||
= 1000;
|
||||
|
||||
/// <summary>
|
||||
/// 总记录数
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public int Total { get; set; }
|
||||
= 0;
|
||||
}
|
||||
|
||||
#region BaseReply
|
||||
|
||||
/// <summary>
|
||||
/// 基础响应
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public class BaseReply
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否成功
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Guid ExecGuid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 执行码
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public int Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否成功
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 执行消息
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string Msg { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数据API
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string? DataApi { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数据主体
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public object? DataTable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数据对象
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public object? DataObject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 列信息
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public List<ReplyColumn>? Columns { get; set; }
|
||||
= new List<ReplyColumn>();
|
||||
|
||||
/// <summary>
|
||||
/// 分页信息
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public BaseReplyPagination Pagination { get; set; }
|
||||
= new BaseReplyPagination();
|
||||
|
||||
#region Create
|
||||
|
||||
/// <summary>
|
||||
/// 创建基础响应对象
|
||||
/// </summary>
|
||||
/// <param name="msg"></param>
|
||||
/// <returns></returns>
|
||||
public static BaseReply Create(string msg)
|
||||
{
|
||||
var reply = new BaseReply();
|
||||
reply.Msg = msg;
|
||||
|
||||
reply.ReplySuccess();
|
||||
return reply;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Create
|
||||
|
||||
/// <summary>
|
||||
/// 创建基础响应对象
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static BaseReply Create(List<object> data, List<ReplyColumn>? columns = null)
|
||||
{
|
||||
var reply = new BaseReply();
|
||||
reply.DataTable = data;
|
||||
reply.Columns = columns;
|
||||
|
||||
if (data != null)
|
||||
reply.Pagination.Total = data.Count;
|
||||
|
||||
reply.ReplySuccess();
|
||||
return reply;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Create
|
||||
|
||||
/// <summary>
|
||||
/// 创建基础响应对象
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static BaseReply Create<T>(List<T> data, List<ReplyColumn>? columns = null)
|
||||
{
|
||||
var reply = new BaseReply();
|
||||
reply.DataTable = data;
|
||||
reply.Columns = columns;
|
||||
|
||||
if (data != null)
|
||||
reply.Pagination.Total = data.Count;
|
||||
|
||||
reply.ReplySuccess();
|
||||
return reply;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateFalt
|
||||
|
||||
/// <summary>
|
||||
/// 创建基础响应对象
|
||||
/// </summary>
|
||||
/// <param name="msg"></param>
|
||||
/// <returns></returns>
|
||||
public static BaseReply CreateFalt(string msg = "失败")
|
||||
{
|
||||
var reply = new BaseReply();
|
||||
reply.Success = false;
|
||||
reply.Code = -1;
|
||||
reply.Msg = msg;
|
||||
return reply;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReplySuccess
|
||||
|
||||
/// <summary>
|
||||
/// 成功
|
||||
/// </summary>
|
||||
public void ReplySuccess()
|
||||
{
|
||||
Success = true;
|
||||
Code = 200;
|
||||
|
||||
if (string.IsNullOrEmpty(Msg))
|
||||
Msg = "成功";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReplyFalt
|
||||
|
||||
/// <summary>
|
||||
/// 失败
|
||||
/// </summary>
|
||||
public void ReplyFalt(string msg = "失败", int code = -1)
|
||||
{
|
||||
Success = false;
|
||||
Code = code;
|
||||
Msg = msg;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 基础响应
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public class Base2Reply
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否成功
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 执行码
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public int Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 执行消息
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string Msg { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数据类型
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public ReplyDataType DataType { get; set; }
|
||||
= ReplyDataType.Object;
|
||||
|
||||
/// <summary>
|
||||
/// 数据主体
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public object? Data { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成功
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
public void ReplySuccess(string data)
|
||||
{
|
||||
Success = true;
|
||||
Code = 0;
|
||||
Msg = "成功";
|
||||
Data = data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成功
|
||||
/// </summary>
|
||||
public void ReplySuccess()
|
||||
{
|
||||
Success = true;
|
||||
Code = 0;
|
||||
Msg = "成功";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 失败
|
||||
/// </summary>
|
||||
public void ReplyFalt()
|
||||
{
|
||||
Success = false;
|
||||
Code = -1;
|
||||
Msg = "失败";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 失败
|
||||
/// </summary>
|
||||
/// <param name="msg"></param>
|
||||
/// <param name="data"></param>
|
||||
public void ReplyFalt(string msg, string? data = null)
|
||||
{
|
||||
Success = false;
|
||||
Code = -1;
|
||||
Msg = msg;
|
||||
Data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 响应数据类型
|
||||
/// </summary>
|
||||
public enum ReplyDataType
|
||||
{
|
||||
/// <summary>
|
||||
/// 空类型
|
||||
/// </summary>
|
||||
Empty,
|
||||
|
||||
/// <summary>
|
||||
/// 字符串类型
|
||||
/// </summary>
|
||||
String,
|
||||
|
||||
/// <summary>
|
||||
/// 对象类型
|
||||
/// </summary>
|
||||
Object,
|
||||
|
||||
/// <summary>
|
||||
/// 列表类型
|
||||
/// </summary>
|
||||
ObjectList,
|
||||
|
||||
/// <summary>
|
||||
/// 动态对象类型
|
||||
/// </summary>
|
||||
ExpandoObject,
|
||||
|
||||
/// <summary>
|
||||
/// 动态对象类型
|
||||
/// </summary>
|
||||
ExpandoObjectList,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 响应列
|
||||
/// </summary>
|
||||
public class ReplyColumn
|
||||
{
|
||||
public ReplyColumn()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
public ReplyColumn(string name, string caption)
|
||||
{
|
||||
Name = name;
|
||||
Caption = caption;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 列名
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string Name { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 列标题
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string Caption { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 列宽度
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public double Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否可见
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public bool IsVisible { get; set; }
|
||||
= true;
|
||||
|
||||
/// <summary>
|
||||
/// 格式化字符串
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string Format { get; set; }
|
||||
= string.Empty;
|
||||
}
|
||||
}
|
||||
129
SHH.MjpegPlayer/Protocols/CoreImagesModel.cs
Normal file
129
SHH.MjpegPlayer/Protocols/CoreImagesModel.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using Core.Protocol;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace Core.WcfProtocol
|
||||
{
|
||||
[DataContract]
|
||||
public class RegisterModelRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 进程Id
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Int32 ProcId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进程类型
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Int32 ProcType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进程通信号
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Int32 ProcChannel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进程启动时间
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Int64 ProcStartTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收消息端口
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Int32 AcceptPort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册结果
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public class RegisterModelReply : Base2Reply
|
||||
{
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class UploadImageRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 唯一标识
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 设备 IP
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string IpAddress { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 进程 ID
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Int32 ProcId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片序号
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public UInt64 Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string Name { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string Type { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 时间
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public DateTime Time { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片数据
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public byte[]? ImageBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图像宽度
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public int ImageWidth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图像高度
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public int ImageHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片数据
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string ImageData { get; set; }
|
||||
= string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 图片上传回复
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public class UploadImageReply : Base2Reply
|
||||
{
|
||||
}
|
||||
}
|
||||
26
SHH.MjpegPlayer/Protocols/ICoreImagesService.cs
Normal file
26
SHH.MjpegPlayer/Protocols/ICoreImagesService.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using CoreWCF;
|
||||
|
||||
namespace Core.WcfProtocol
|
||||
{
|
||||
/// <summary>
|
||||
/// CoreImagesService 接口
|
||||
/// </summary>
|
||||
[ServiceContract]
|
||||
public interface ICoreImagesService
|
||||
{
|
||||
/// <summary>
|
||||
/// 上传图片
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
/// <returns></returns>
|
||||
[OperationContract]
|
||||
UploadImageReply UploadImage(UploadImageRequest req);
|
||||
|
||||
/// <summary>
|
||||
/// 上传图片无返回结果
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
[OperationContract(IsOneWay = true)]
|
||||
void UploadImageOneWay(UploadImageRequest req);
|
||||
}
|
||||
}
|
||||
21
SHH.MjpegPlayer/Protocols/IMjpegImagesService.cs
Normal file
21
SHH.MjpegPlayer/Protocols/IMjpegImagesService.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Core.WcfProtocol;
|
||||
using CoreWCF;
|
||||
using SHH.MjpegPlayer;
|
||||
|
||||
namespace Player.MJPEG
|
||||
{
|
||||
/// <summary>
|
||||
/// IMjpegImagesService 接口
|
||||
/// </summary>
|
||||
[ServiceContract]
|
||||
public interface IMjpegImagesService : ICoreImagesService
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册模型
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
/// <returns></returns>
|
||||
[OperationContract]
|
||||
MjpegPlayInfoReply GetRtspRtcPlayInfo();
|
||||
}
|
||||
}
|
||||
61
SHH.MjpegPlayer/Protocols/MjpegPlayInfoReply.cs
Normal file
61
SHH.MjpegPlayer/Protocols/MjpegPlayInfoReply.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Core.Protocol;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Mjpeg播放信息回复
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public class MjpegPlayInfoReply : BaseReply
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回的信息集合
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public List<MjpegPlayInfo> Infos { get; set; }
|
||||
= new List<MjpegPlayInfo>();
|
||||
}
|
||||
|
||||
public class MjpegPlayInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 摄像头 ID
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Int32 CameraId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分析类型代码
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public int AITypeCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分析类型
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string AIType { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Rtsp 端口
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Int32 RtspPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户名
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string Account { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 密码
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public string Password { get; set; }
|
||||
= string.Empty;
|
||||
}
|
||||
}
|
||||
22
SHH.MjpegPlayer/SHH.MjpegPlayer.csproj
Normal file
22
SHH.MjpegPlayer/SHH.MjpegPlayer.csproj
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CoreWCF.Http" Version="1.8.0" />
|
||||
<PackageReference Include="CoreWCF.Primitives" Version="1.8.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageReference Include="Prism.Core" Version="8.1.97" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj" />
|
||||
<ProjectReference Include="..\SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
75
SHH.MjpegPlayer/Server/CoreImagesService.cs
Normal file
75
SHH.MjpegPlayer/Server/CoreImagesService.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Ayay.SerilogLogs;
|
||||
using Core.WcfProtocol;
|
||||
using Serilog;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// CoreImagesService 服务
|
||||
/// </summary>
|
||||
public class CoreImagesService : ICoreImagesService
|
||||
{
|
||||
private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
|
||||
|
||||
#region Defines
|
||||
|
||||
/// <summary>
|
||||
/// 按秒统计
|
||||
/// </summary>
|
||||
public static SumByTime _sumBySecond = new SumByTime();
|
||||
|
||||
#endregion
|
||||
|
||||
#region UploadImage
|
||||
|
||||
/// <summary>
|
||||
/// 上传图片
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
/// <returns></returns>
|
||||
public UploadImageReply UploadImage(UploadImageRequest req)
|
||||
{
|
||||
var reply = new UploadImageReply();
|
||||
|
||||
try
|
||||
{
|
||||
// 日志准备
|
||||
_sumBySecond.Refresh("UploadImage");
|
||||
|
||||
PrismMsg<UploadImageRequest>.Publish(req);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Warning($"上传图片失败, {ex.Message} {ex.StackTrace}");
|
||||
reply.ReplyFalt(ex.Message, ex.Source);
|
||||
}
|
||||
|
||||
reply.ReplySuccess();
|
||||
return reply;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UploadImageOneWay
|
||||
|
||||
/// <summary>
|
||||
/// 上传图片
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
public void UploadImageOneWay(UploadImageRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sumBySecond.Refresh("UploadImage");
|
||||
|
||||
PrismMsg<UploadImageRequest>.Publish(req);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Warning($"上传图片失败, {ex.Message} {ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
163
SHH.MjpegPlayer/Server/MjpegHttpCmd.cs
Normal file
163
SHH.MjpegPlayer/Server/MjpegHttpCmd.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// MJPEG HTTP命令类
|
||||
/// </summary>
|
||||
public class MjpegHttpCmd
|
||||
{
|
||||
#region DoHttpCmd
|
||||
|
||||
public static bool DoHttpCmd(NetworkStream stream,
|
||||
SessionInfo Info, string Cmd)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (Cmd)
|
||||
{
|
||||
case "view":
|
||||
Info.Message = "执行 view 命令.";
|
||||
DoHttpCmdView(stream, Info);
|
||||
return true;
|
||||
|
||||
case "task":
|
||||
Info.Message = "执行 task 命令.";
|
||||
DoHttpCmdTask(stream, Info);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SendJson(stream, $"Command Failed: {ex.Message}", 400);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoHttpCmdView
|
||||
|
||||
private static void DoHttpCmdView(NetworkStream stream, SessionInfo Info)
|
||||
{
|
||||
var sessions = new List<SessionInfo>();
|
||||
int iSessionCount = 0;
|
||||
|
||||
var allSessions = MjpegStatics.Sessions.GetAllSessionInfos();
|
||||
|
||||
foreach (var sessionInfo in allSessions)
|
||||
{
|
||||
if (sessionInfo == null) continue;
|
||||
|
||||
if (!string.IsNullOrEmpty(Info.DeviceId))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sessionInfo.DeviceId)
|
||||
&& !sessionInfo.DeviceId.Equals(Info.DeviceId))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Info.TypeCode))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sessionInfo.TypeCode)
|
||||
&& !sessionInfo.TypeCode.Equals(Info.TypeCode))
|
||||
continue;
|
||||
}
|
||||
|
||||
iSessionCount++;
|
||||
sessions.Add(sessionInfo);
|
||||
}
|
||||
|
||||
var chns = new List<ImageChannel>();
|
||||
var imgChns = MjpegStatics.ImageChannels;
|
||||
int iImgChanCount = 0;
|
||||
|
||||
foreach (var kvp in imgChns.Channels)
|
||||
{
|
||||
var imgChannel = kvp.Value;
|
||||
if (imgChannel == null) continue;
|
||||
|
||||
if (!string.IsNullOrEmpty(Info.DeviceId))
|
||||
{
|
||||
if (!imgChannel.DeviceId.ToString().Equals(Info.DeviceId))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Info.TypeCode))
|
||||
{
|
||||
if (!imgChannel.Type.Equals(Info.TypeCode))
|
||||
continue;
|
||||
}
|
||||
|
||||
iImgChanCount++;
|
||||
chns.Add(imgChannel);
|
||||
}
|
||||
|
||||
var result = new
|
||||
{
|
||||
webAccessCount = iSessionCount,
|
||||
deviceChannelCount = iImgChanCount,
|
||||
webAccessItems = sessions,
|
||||
deviceChannels = chns
|
||||
};
|
||||
|
||||
SendJson(stream, JsonConvert.SerializeObject(result, Formatting.Indented));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoHttpCmdTask
|
||||
|
||||
private static void DoHttpCmdTask(NetworkStream stream, SessionInfo Info)
|
||||
{
|
||||
// [Optimized]: 直接从 TaskManager 获取实时任务快照,避免遍历旧的静态字典
|
||||
var activeTasks = TaskManager.RunningTasks.Values.ToList();
|
||||
|
||||
int iTaskCount = activeTasks.Count;
|
||||
int iMjpegServerListenCount = activeTasks.Count(t => t.Name.Contains("MjpegServer-"));
|
||||
|
||||
var result = new
|
||||
{
|
||||
taskCount = iTaskCount,
|
||||
portListenCount = iMjpegServerListenCount,
|
||||
// 映射为前端需要的格式
|
||||
taskItems = activeTasks.Select(t => new { t.Name, t.Type })
|
||||
};
|
||||
|
||||
// 使用 Newtonsoft.Json 或 System.Text.Json 输出
|
||||
SendJson(stream, JsonConvert.SerializeObject(result, Formatting.Indented));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper
|
||||
|
||||
private static void SendJson(NetworkStream stream, string json, int code = 200)
|
||||
{
|
||||
try
|
||||
{
|
||||
string statusLine = code == 200 ? "200 OK" : "400 Bad Request";
|
||||
|
||||
// [修复] 添加 CORS 头,允许诊断页面跨域访问
|
||||
byte[] response = Encoding.UTF8.GetBytes(
|
||||
$"HTTP/1.1 {statusLine}\r\n" +
|
||||
"Access-Control-Allow-Origin: *\r\n" +
|
||||
"Access-Control-Allow-Methods: GET, POST\r\n" +
|
||||
"Content-Type: application/json; charset=utf-8\r\n" +
|
||||
$"Content-Length: {Encoding.UTF8.GetByteCount(json)}\r\n\r\n" +
|
||||
json
|
||||
);
|
||||
|
||||
stream.Write(response, 0, response.Length);
|
||||
stream.Flush();
|
||||
stream.Close();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
28
SHH.MjpegPlayer/Server/MjpegImagesService.cs
Normal file
28
SHH.MjpegPlayer/Server/MjpegImagesService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Player.MJPEG;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// MjpegImagesService 服务
|
||||
/// </summary>
|
||||
public class MjpegImagesService : CoreImagesService, IMjpegImagesService
|
||||
{
|
||||
#region GetRtspRtcPlayInfo
|
||||
|
||||
/// <summary>
|
||||
/// 获取 RtspRtc 播放信息
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MjpegPlayInfoReply GetRtspRtcPlayInfo()
|
||||
{
|
||||
var reply = new MjpegPlayInfoReply();
|
||||
|
||||
// 发送消息
|
||||
PrismMsg<MjpegPlayInfoReply>.Publish(reply);
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
98
SHH.MjpegPlayer/Server/MjpegServer.cs
Normal file
98
SHH.MjpegPlayer/Server/MjpegServer.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Mjpeg 服务
|
||||
/// </summary>
|
||||
public class MjpegServer
|
||||
{
|
||||
// [修复] 静态列表管理监听器,支持优雅停止
|
||||
private static readonly List<TcpListener> _listeners = new List<TcpListener>();
|
||||
private static readonly object _lock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// 启动服务
|
||||
/// </summary>
|
||||
/// <param name="port"></param>
|
||||
public static void Start(int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 示例:在 MjpegServer 初始化循环中调用
|
||||
TaskManager.Run($"MjpegServer-{port}", "Network", async (token) =>
|
||||
{
|
||||
// [Modified]: 使用 TaskManager 托管,支持外部取消令牌 token
|
||||
try
|
||||
{
|
||||
var cfg = MjpegStatics.Cfg;
|
||||
IPAddress ipAddress = IPAddress.Any;
|
||||
|
||||
if (!string.IsNullOrEmpty(cfg.SvrMjpegIp) && IPAddress.TryParse(cfg.SvrMjpegIp, out var parsedIp))
|
||||
{
|
||||
ipAddress = parsedIp;
|
||||
}
|
||||
|
||||
var server = new TcpListener(ipAddress, port);
|
||||
lock (_lock) _listeners.Add(server);
|
||||
|
||||
server.Start();
|
||||
// Logs.LogInformation...
|
||||
|
||||
try
|
||||
{
|
||||
// [Modified]: 检查取消令牌和全局运行状态
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 使用 AcceptTcpClientAsync 的重载或在外部检查
|
||||
var client = await server.AcceptTcpClientAsync();
|
||||
if (client == null) continue;
|
||||
|
||||
var session = new MjpegSession();
|
||||
session.Create(client);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// [修复] 异常防暴
|
||||
await Task.Delay(1000, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// [修复] 任务退出清理
|
||||
try { server.Stop(); } catch { }
|
||||
lock (_lock) _listeners.Remove(server);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Logs.LogError... 捕获初始化异常
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Logs.LogError<MjpegServer>(ex.Message, ex.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止所有服务 (新增)
|
||||
/// </summary>
|
||||
public static void StopAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var server in _listeners)
|
||||
{
|
||||
try { server.Stop(); } catch { }
|
||||
}
|
||||
_listeners.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
341
SHH.MjpegPlayer/Server/MjpegSession.cs
Normal file
341
SHH.MjpegPlayer/Server/MjpegSession.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
using Ayay.SerilogLogs;
|
||||
using Core.WcfProtocol;
|
||||
using Serilog;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Mjpeg 会话工作单元
|
||||
/// </summary>
|
||||
public class MjpegSession : IDisposable
|
||||
{
|
||||
private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
|
||||
|
||||
#region Counter
|
||||
|
||||
private SumByTime _sumBySecond = new SumByTime();
|
||||
/// <summary>
|
||||
/// 计数器
|
||||
/// </summary>
|
||||
public SumByTime Counter => _sumBySecond;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Info
|
||||
|
||||
/// <summary>
|
||||
/// 基础信息
|
||||
/// </summary>
|
||||
public SessionInfo Info { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cmd
|
||||
|
||||
/// <summary>
|
||||
/// 命令
|
||||
/// </summary>
|
||||
public string? Cmd { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
// [修复] 引入 Disposed 标志位
|
||||
private volatile bool _isDisposed = false;
|
||||
|
||||
#region Constructor
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
public MjpegSession()
|
||||
{
|
||||
Info = new SessionInfo
|
||||
{
|
||||
Counter = _sumBySecond
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoHttpHeader
|
||||
|
||||
private bool DoHttpHeader(NetworkStream stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] buffer = new byte[4096];
|
||||
int totalBytesRead = 0;
|
||||
string httpRequest = string.Empty;
|
||||
|
||||
while (totalBytesRead < buffer.Length)
|
||||
{
|
||||
int bytesRead = stream.Read(buffer, totalBytesRead, buffer.Length - totalBytesRead);
|
||||
if (bytesRead == 0) return false;
|
||||
|
||||
totalBytesRead += bytesRead;
|
||||
httpRequest = Encoding.ASCII.GetString(buffer, 0, totalBytesRead);
|
||||
if (httpRequest.Contains("\r\n\r\n")) break;
|
||||
}
|
||||
|
||||
if (!httpRequest.Contains("\r\n\r\n")) return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(httpRequest))
|
||||
{
|
||||
int queryStartIndex = httpRequest.IndexOf('?');
|
||||
if (queryStartIndex > -1)
|
||||
{
|
||||
int spaceIndex = httpRequest.IndexOf(' ', queryStartIndex);
|
||||
if (spaceIndex == -1) spaceIndex = httpRequest.Length;
|
||||
string queryString = httpRequest.Substring(queryStartIndex + 1, spaceIndex - queryStartIndex - 1);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(queryString);
|
||||
if (queryParams != null)
|
||||
{
|
||||
Info.DeviceId = queryParams["id"];
|
||||
Info.TypeCode = queryParams["typeCode"];
|
||||
Cmd = queryParams["cmd"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Cmd))
|
||||
{
|
||||
if (string.IsNullOrEmpty(Info.DeviceId) || string.IsNullOrEmpty(Info.TypeCode))
|
||||
{
|
||||
SendErrorResponse(stream, "错误:缺少必要参数(id 或 typeCode)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (MjpegHttpCmd.DoHttpCmd(stream, Info, Cmd)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SendErrorResponse(stream, $"解析异常: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SendErrorResponse(NetworkStream stream, string msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] response = Encoding.UTF8.GetBytes(
|
||||
$"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{msg}");
|
||||
stream.Write(response, 0, response.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Create
|
||||
|
||||
/// <summary>
|
||||
/// 创建会话
|
||||
/// </summary>
|
||||
public void Create(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Info == null) return;
|
||||
Info.AcceptTime = DateTime.Now;
|
||||
// 初始化最近接收时间,避免刚连接就被判定为超时
|
||||
LastRecImgTime = DateTime.Now;
|
||||
|
||||
Task.Run(() => { DoWorkTask(client); });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error($"MjpegSession Create Exception, 异常信息:{ex.Message}, {ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoWorkTask
|
||||
|
||||
private void DoWorkTask(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var stream = client.GetStream())
|
||||
{
|
||||
// 设置写入超时 3秒
|
||||
stream.WriteTimeout = 3000;
|
||||
|
||||
#region 等待连接, 处理地址栏参数
|
||||
|
||||
int iLoc = 0;
|
||||
while (!client.Connected)
|
||||
{
|
||||
Thread.Sleep(50);
|
||||
if (++iLoc > 60) return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (client.Client?.RemoteEndPoint is IPEndPoint endpoint)
|
||||
{
|
||||
Info.ClientIp = endpoint.Address.ToString();
|
||||
Info.ClientPort = endpoint.Port;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (!DoHttpHeader(stream)) return;
|
||||
|
||||
#endregion
|
||||
|
||||
MjpegStatics.Sessions.AddSession(this);
|
||||
|
||||
byte[] header = Encoding.ASCII.GetBytes(
|
||||
"HTTP/1.1 200 OK\r\n" +
|
||||
"Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n");
|
||||
stream.Write(header, 0, header.Length);
|
||||
|
||||
var frameInterval = MjpegStatics.Cfg.FrameInterval;
|
||||
if (frameInterval < 1 || frameInterval > 500) frameInterval = 125;
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
UploadImageRequest? lastProcItem = null;
|
||||
|
||||
byte[] boundaryBytes = Encoding.ASCII.GetBytes("\r\n--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ");
|
||||
byte[] doubleNewLine = Encoding.ASCII.GetBytes("\r\n\r\n");
|
||||
|
||||
while (client.Connected && !_isDisposed)
|
||||
{
|
||||
try
|
||||
{
|
||||
// [新增] 僵尸连接熔断机制:如果源头超过 60 秒没有新图片,主动断开连接
|
||||
if ((DateTime.Now - LastRecImgTime).TotalSeconds > 60)
|
||||
{
|
||||
_sysLog.Warning($"会话超时断开 (源头无数据 > 60s): {Info.Key} - Client: {Info.ClientIp}");
|
||||
break;
|
||||
}
|
||||
|
||||
stopwatch.Restart();
|
||||
|
||||
if (_lastRecObj == null || _lastRecObj.ImageBytes == null)
|
||||
{
|
||||
Info.Message = "等待图片数据抵达";
|
||||
Thread.Sleep(40);
|
||||
continue;
|
||||
}
|
||||
|
||||
Info.Message = "视频流播放中";
|
||||
if (lastProcItem != null && lastProcItem != _lastRecObj)
|
||||
{
|
||||
_sumBySecond.Refresh("有效帧数");
|
||||
}
|
||||
lastProcItem = _lastRecObj;
|
||||
|
||||
// 如果图片太旧(超过3秒),认为是滞后数据,暂时不发
|
||||
if ((DateTime.Now - _lastRecObj.Time).TotalSeconds > 3)
|
||||
{
|
||||
Thread.Sleep(40);
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] imageData = _lastRecObj.ImageBytes;
|
||||
|
||||
stream.Write(boundaryBytes, 0, boundaryBytes.Length);
|
||||
byte[] lengthBytes = Encoding.ASCII.GetBytes(imageData.Length.ToString());
|
||||
stream.Write(lengthBytes, 0, lengthBytes.Length);
|
||||
stream.Write(doubleNewLine, 0, doubleNewLine.Length);
|
||||
stream.Write(imageData, 0, imageData.Length);
|
||||
|
||||
_sumBySecond.Refresh("播放帧数");
|
||||
|
||||
stopwatch.Stop();
|
||||
var needSleep = frameInterval - (int)stopwatch.ElapsedMilliseconds;
|
||||
if (needSleep > 0) Thread.Sleep(needSleep);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Warning($"播放连接断开, : {ex.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error($"异常信息:{ex.Message}, {ex.StackTrace}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Dispose();
|
||||
if (client != null)
|
||||
{
|
||||
try { client.Close(); client.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoImageProc
|
||||
|
||||
private UInt64 _lastPlayImageOrder = 0;
|
||||
private UploadImageRequest? _lastRecObj = null;
|
||||
|
||||
/// <summary>
|
||||
/// 处置图片
|
||||
/// </summary>
|
||||
public void DoImageProc(UploadImageRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
LastRecImgTime = DateTime.Now;
|
||||
_sumBySecond.Refresh("接收帧数");
|
||||
if (req == null || req.ImageBytes == null || req.ImageBytes.Length < 100) return;
|
||||
|
||||
// 防止死锁:序号重置检测
|
||||
if (req.Order < _lastPlayImageOrder)
|
||||
{
|
||||
if (_lastPlayImageOrder > 100 && req.Order < 100)
|
||||
{
|
||||
_lastPlayImageOrder = req.Order;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_lastPlayImageOrder = req.Order;
|
||||
_lastRecObj = req;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error($"异常信息:{ex.Message}, {ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 最近收到图片时间
|
||||
/// </summary>
|
||||
public DateTime LastRecImgTime { get; set; } = DateTime.MinValue;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
MjpegStatics.Sessions.RemoveSession(this);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
116
SHH.MjpegPlayer/Server/MjpegSessions.cs
Normal file
116
SHH.MjpegPlayer/Server/MjpegSessions.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using Core.WcfProtocol;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// 服务器会话集合 (线程安全重构版)
|
||||
/// </summary>
|
||||
public class MjpegSessions
|
||||
{
|
||||
// 核心改变:使用字典建立索引,Key = DeviceId#TypeCode
|
||||
// 这样可以将查找特定摄像头的复杂度从 O(N) 降低到 O(1)
|
||||
private readonly ConcurrentDictionary<string, List<MjpegSession>> _sessionMap
|
||||
= new ConcurrentDictionary<string, List<MjpegSession>>();
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
public MjpegSessions()
|
||||
{
|
||||
PrismMsg<UploadImageRequest>.Subscribe(ProcUploadImageRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优化后的图片分发逻辑 (解决 O(N) 和 线程安全问题)
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
public void ProcUploadImageRequest(UploadImageRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
string key = $"{req.Id}#{req.Type}";
|
||||
|
||||
// 1. 更新通道信息 (ImageChannels 现已是线程安全的)
|
||||
var chn = MjpegStatics.ImageChannels.Do(req, key);
|
||||
|
||||
// 2. O(1) 快速查找关注该流的会话列表
|
||||
if (_sessionMap.TryGetValue(key, out var targetSessions))
|
||||
{
|
||||
// 必须加锁,防止遍历时 List 被其他线程(如 AddSession/RemoveSession) 修改
|
||||
lock (targetSessions)
|
||||
{
|
||||
// 倒序遍历,方便在需要时移除失效会话
|
||||
for (var i = targetSessions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var session = targetSessions[i];
|
||||
session.DoImageProc(req);
|
||||
}
|
||||
}
|
||||
|
||||
if (chn != null) chn.IsPlaying = targetSessions.Count > 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (chn != null) chn.IsPlaying = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Logs.LogWarning<MjpegSessions>(ex.Message, ex.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加会话
|
||||
/// </summary>
|
||||
/// <param name="session"></param>
|
||||
public void AddSession(MjpegSession session)
|
||||
{
|
||||
if (session?.Info?.Key == null) return;
|
||||
|
||||
// 使用 GetOrAdd 确保线程安全地获取或创建 List
|
||||
var list = _sessionMap.GetOrAdd(session.Info.Key, _ => new List<MjpegSession>());
|
||||
|
||||
lock (list)
|
||||
{
|
||||
list.Add(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除会话
|
||||
/// </summary>
|
||||
/// <param name="session"></param>
|
||||
public void RemoveSession(MjpegSession session)
|
||||
{
|
||||
if (session?.Info?.Key == null) return;
|
||||
|
||||
if (_sessionMap.TryGetValue(session.Info.Key, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
list.Remove(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前所有会话信息的快照 (用于 HTTP API 统计与展示)
|
||||
/// [新增] 此方法替代旧版直接访问 Sessions 列表,防止 HTTP 线程与 MJPEG 线程发生冲突
|
||||
/// </summary>
|
||||
public List<SessionInfo> GetAllSessionInfos()
|
||||
{
|
||||
var result = new List<SessionInfo>();
|
||||
// 遍历字典,线程安全地收集所有 Info
|
||||
foreach (var kvp in _sessionMap)
|
||||
{
|
||||
// 对内部 List 加锁,确保复制过程不被打断
|
||||
lock (kvp.Value)
|
||||
{
|
||||
result.AddRange(kvp.Value.Select(s => s.Info));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
163
SHH.MjpegPlayer/Server/RtmpPushServer.cs
Normal file
163
SHH.MjpegPlayer/Server/RtmpPushServer.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using Ayay.SerilogLogs;
|
||||
using Serilog;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// RTMP 推流参数同步服务器
|
||||
/// 职责:定期将本地图片通道信息同步至流媒体服务器,并获取最新的 RTMP 推流地址。
|
||||
/// </summary>
|
||||
public class RtmpPushServer
|
||||
{
|
||||
private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
|
||||
|
||||
#region Instance
|
||||
|
||||
/// <summary>
|
||||
/// 获取 RTMP 推流处理器的全局单例实例
|
||||
/// </summary>
|
||||
public static RtmpPushServer Instance { get; } = new RtmpPushServer();
|
||||
|
||||
// 私有构造函数防止外部 new
|
||||
private RtmpPushServer() { }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Start
|
||||
|
||||
/// <summary>
|
||||
/// 启动 RTMP 推流任务 (对接新架构 TaskManager)
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
// Optimized: 使用 TaskManager.Run 替代旧的线程启动方式,实现任务可视化管理
|
||||
TaskManager.Run("RtmpPushSync", "Monitor", async (token) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_sysLog.Information("RTMP 推流同步任务正在启动...");
|
||||
|
||||
// 1. 初始化延迟:稍作延迟,等待系统其他组件初始化完成
|
||||
await Task.Delay(2000, token);
|
||||
|
||||
// 2. 配置校验
|
||||
if (!MjpegStatics.Cfg.UseRtmpServer)
|
||||
{
|
||||
_sysLog.Warning("配置未启用 RTMP 服务,推流任务已跳过");
|
||||
return;
|
||||
}
|
||||
|
||||
#region 启动连接检测
|
||||
|
||||
bool isConnected = false;
|
||||
while (!isConnected && !token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfg = MjpegStatics.Cfg;
|
||||
var testItems = new List<object> { new { deviceIp = "", deviceId = "", algCode = "" } };
|
||||
|
||||
var result = testItems.PostJson<CfgRtmpReply>(cfg.RtmpServerDjhUri);
|
||||
if (result != null && result.IsSuccess)
|
||||
{
|
||||
_sysLog.Information("流媒体服务接口检测通过: {Uri}", cfg.RtmpServerDjhUri);
|
||||
isConnected = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_sysLog.Warning("检测流媒体服务接口失败, 30秒后再试... Uri: {Uri}", cfg.RtmpServerDjhUri);
|
||||
await Task.Delay(30000, token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error(ex, "流媒体服务接口检测异常");
|
||||
await Task.Delay(5000, token);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 周期性同步任务 (代替 OnDoTaskAction)
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
// 执行业务同步逻辑
|
||||
await SyncRtmpParametersAsync(token);
|
||||
|
||||
// 每 50ms 执行一次 (对应原 Start 中的 50ms 频率)
|
||||
await Task.Delay(50, token);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_sysLog.Information("RTMP 推流同步任务已正常取消");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Fatal(ex, "RTMP 推流任务发生致命错误");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 核心业务逻辑
|
||||
|
||||
private async Task SyncRtmpParametersAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfg = MjpegStatics.Cfg;
|
||||
var imgChns = MjpegStatics.ImageChannels;
|
||||
var pushItems = new List<object>();
|
||||
|
||||
// 构建上报数据
|
||||
foreach (var kvp in imgChns.Channels)
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
|
||||
var imgChn = kvp.Value;
|
||||
if (imgChn == null || !imgChn.UseRtmp) continue;
|
||||
|
||||
pushItems.Add(new
|
||||
{
|
||||
deviceIp = string.IsNullOrEmpty(imgChn.IpAddress) ? "127.0.0.1" : imgChn.IpAddress,
|
||||
deviceId = imgChn.DeviceId.ToString(),
|
||||
algCode = imgChn.Type
|
||||
});
|
||||
}
|
||||
|
||||
if (pushItems.Count > 0)
|
||||
{
|
||||
// 使用 await 配合异步扩展,释放线程池线程
|
||||
// 如果你的 NetHttpExtension 已支持异步,则直接 await。
|
||||
// 否则使用 await Task.Run(() => pushItems.PostJson(...)) 暂时过渡。
|
||||
var result = await pushItems.PostJsonAsync<CfgRtmpReply>(cfg.RtmpServerDjhUri, 2000);
|
||||
|
||||
if (result?.rtmpVoList != null && result.IsSuccess)
|
||||
{
|
||||
foreach (var item in result.rtmpVoList)
|
||||
{
|
||||
var channel = imgChns.Get(item.deviceId, item.algCode);
|
||||
if (channel != null && channel.RtmpUri != item.rtmp)
|
||||
{
|
||||
// 假设后续我们会统计实例中的成功次数
|
||||
channel.RtmpUri = item.rtmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 使用统一的 Serilog 对象输出结构化日志
|
||||
_sysLog.Error(ex, "SyncRtmpParametersAsync 执行异常");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
48
SHH.MjpegPlayer/Server/TaskManager.cs
Normal file
48
SHH.MjpegPlayer/Server/TaskManager.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// 任务状态信息载荷
|
||||
/// </summary>
|
||||
public record TaskMetadata(string Name, string Type, DateTime StartTime);
|
||||
|
||||
/// <summary>
|
||||
/// 任务管理器:替代原 CoreTaskRun 功能
|
||||
/// 职责:记录运行中的异步任务,支持状态检索和统一取消
|
||||
/// </summary>
|
||||
public static class TaskManager
|
||||
{
|
||||
// 存储运行中的任务及其元数据
|
||||
public static readonly ConcurrentDictionary<string, TaskMetadata> RunningTasks = new();
|
||||
|
||||
// 存储取消令牌,用于停止特定任务
|
||||
private static readonly ConcurrentDictionary<string, CancellationTokenSource> _tokens = new();
|
||||
|
||||
/// <summary>
|
||||
/// 注册并运行一个受控任务
|
||||
/// </summary>
|
||||
public static void Run(string taskName, string taskType, Func<CancellationToken, Task> action)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
_tokens[taskName] = cts;
|
||||
|
||||
var metadata = new TaskMetadata(taskName, taskType, DateTime.Now);
|
||||
RunningTasks[taskName] = metadata;
|
||||
|
||||
// 启动异步任务
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await action(cts.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Optimized: 任务结束(无论正常还是异常)必须清理资源
|
||||
RunningTasks.TryRemove(taskName, out _);
|
||||
_tokens.TryRemove(taskName, out _);
|
||||
}
|
||||
}, cts.Token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user