新增 Mjpegplayer 用来播放 Web 流

This commit is contained in:
2026-01-21 19:03:59 +08:00
parent f79cb6e74d
commit c438edfa0d
71 changed files with 4538 additions and 452 deletions

View 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
}
}

View 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
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}

View 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;
}

View 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,
}
}

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}
}
}
}

View 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
}
}

View 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
}

View 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;
}
}
}

View 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();
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View File

@@ -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}");
}
}
}
}

View File

@@ -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;
}
}
}

View 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 服务已停止。");
}
}
}
}

View 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

View 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
View 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
}
}

View 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;
}
}

View 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
{
}
}

View 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);
}
}

View 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();
}
}

View 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;
}
}

View 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>

View 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
}
}

View 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
}
}

View 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
}
}

View 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();
}
}
}
}

View 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
}
}

View 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;
}
}

View 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
}
}

View 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);
}
}