diff --git a/SHH.CameraDashboard/App.xaml.cs b/SHH.CameraDashboard/App.xaml.cs index 8b97667..3c720c8 100644 --- a/SHH.CameraDashboard/App.xaml.cs +++ b/SHH.CameraDashboard/App.xaml.cs @@ -1,4 +1,4 @@ -using SHH.CameraDashboard.Services; +using MessagePack; using SHH.Contracts; using SHH.ProcessLaunchers; using System.Collections.ObjectModel; @@ -30,9 +30,13 @@ namespace SHH.CameraDashboard StreamReceiverService.Instance.Start(6002); // 启动指令服务 (Port 6001) - CommandServer.Instance.Start(6001); - CommandServer.Instance.OnClientRegistered += SetupAutomaticConfiguration; + CommandBusClient.Instance.Start(6001); + CommandBusClient.Instance.OnServerRegistered += SetupAutomaticConfiguration; + + //CommandServer.Instance.Start(6001); + + //CommandServer.Instance.OnClientRegistered += SetupAutomaticConfiguration; // 现在我们来配置启动 @@ -59,9 +63,10 @@ namespace SHH.CameraDashboard string serviceArgs = $"" + $"--pid {myPid} " + $"--appid \"CameraApp_01\" " + - $"--uris \"127.0.0.1,6002,video,调试PC;\" " + + $"--uris \"127.0.0.1,6003,video,调试PC;\" " + $"--uris \"127.0.0.1,6001,command,调试PC;\" " + - $"--uris \"192.168.1.100,6002,video,大屏展示;\" " + + $"--uris \"127.0.0.1,6004,command,调试PC;\" " + + $"--uris \"127.0.0.1,6002,video,大屏展示;\" " + $"--mode 1 " + $"--ports \"5000,100\""; @@ -71,7 +76,7 @@ namespace SHH.CameraDashboard Id = "CameraService", // 内部标识 DisplayName = "视频接入服务", // UI显示名称 // 请确保路径正确,建议用相对路径 AppDomain.CurrentDomain.BaseDirectory + "SHH.CameraService.exe" - ExePath = @"D:\Codes\Ayay\SHH.CameraService\bin\Debug\net8.0\SHH.CameraService.exe", + ExePath = @"E:\Codes2026\Ayay\SHH.CameraService\bin\Debug\net8.0\SHH.CameraService.exe", Arguments = serviceArgs, // ★★★ 核心:注入参数 ★★★ StartupOrder = 1, // 优先级 RestartDelayMs = 2000, // 崩溃后2秒重启 @@ -90,6 +95,76 @@ namespace SHH.CameraDashboard mainWin.Show(); } + private void SetupAutomaticConfiguration(RegisterPayload client) + { + Console.WriteLine($"[自动化] 新服务上线: {client.InstanceId}"); + + Task.Run(async () => + { + await Task.Delay(500); + + // 1. 构建业务配置对象 + var cameraConfig = new CameraConfigDto + { + Id = 17798, + Name = "206摄像头", + Location = "404办公室", + IpAddress = "172.16.41.88", + Username = "admin", + Password = "abcd1234", + Port = 8000, + ChannelIndex = 1, + StreamType = 0, + Brand = DeviceBrand.HikVision.GetHashCode(), // 对应 DeviceBrand 枚举 + RenderHandle = 0, // 初始化为0 + MainboardIp = "", // 留空 + MainboardPort = 0, + RtspPath = "" + }; + + // ★ 新增:一并带上订阅要求 ★ + cameraConfig.AutoSubscriptions = new List + { + // 第一条:显示帧,要求 8 帧 + new CameraConfigSubscribeDto { + AppId = "UI_Display", + Type = 0, + TargetFps = 8, + Memo = "显示帧" + }, + // 第二条:分析帧,要求 1 帧 + new CameraConfigSubscribeDto { + AppId = "AI_Analysis", + Type = 0, + Memo = "分析帧", + TargetFps = 1 + } + }; + + // 2. 构造指令包 + var command = new CommandPayload + { + Protocol = ProtocolHeaders.Command, + CmdCode = ProtocolHeaders.SyncCamera, + TargetId = client.InstanceId, + RequestId = Guid.NewGuid().ToString("N"), + + // ★ 修正 1: 使用 JsonParams 属性名,并将对象序列化为 JSON 字符串 ★ + // 因为你的 DTO 定义 JsonParams 是 string 类型 + JsonParams = JsonHelper.Serialize(cameraConfig), + + // ★ 修正 2: Timestamp 直接赋值 DateTime 对象 ★ + // 因为你的 DTO 定义 Timestamp 是 DateTime 类型 + Timestamp = DateTime.Now, + + RequireAck = true + }; + + // 3. 发送 + await CommandBusClient.Instance.SendInternalAsync(client.InstanceId, command); + }); + } + /// /// 在程序启动时订阅事件 /// diff --git a/SHH.CameraDashboard/Invokes/Interceptors/InterceptorPipeline.cs b/SHH.CameraDashboard/Invokes/Interceptors/InterceptorPipeline.cs new file mode 100644 index 0000000..8f5f598 --- /dev/null +++ b/SHH.CameraDashboard/Invokes/Interceptors/InterceptorPipeline.cs @@ -0,0 +1,47 @@ +namespace SHH.CameraDashboard +{ + // 简单的上下文定义 + public class ProtocolContext + { + public string Protocol { get; set; } + public byte[] Data { get; set; } + public bool IsBlocked { get; set; } = false; + public ProtocolContext(string p, byte[] d) { Protocol = p; Data = d; } + } + + public interface IProtocolInterceptor + { + Task OnSendingAsync(ProtocolContext context); + Task OnReceivedAsync(ProtocolContext context); + } + + public class InterceptorPipeline + { + // 因为 Dashboard 可能没有复杂的 DI,这里支持手动添加列表 + private readonly List _interceptors = new List(); + + public void Add(IProtocolInterceptor interceptor) => _interceptors.Add(interceptor); + + public async Task ExecuteSendAsync(string protocol, byte[] data) + { + var ctx = new ProtocolContext(protocol, data); + foreach (var i in _interceptors) + { + await i.OnSendingAsync(ctx); + if (ctx.IsBlocked) return null; + } + return ctx; + } + + public async Task ExecuteReceiveAsync(string protocol, byte[] data) + { + var ctx = new ProtocolContext(protocol, data); + foreach (var i in _interceptors) + { + await i.OnReceivedAsync(ctx); + if (ctx.IsBlocked) return null; + } + return ctx; + } + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/Invokes/StreamReceiverService.cs b/SHH.CameraDashboard/Invokes/StreamReceiverService.cs index 110b494..6085c2c 100644 --- a/SHH.CameraDashboard/Invokes/StreamReceiverService.cs +++ b/SHH.CameraDashboard/Invokes/StreamReceiverService.cs @@ -1,4 +1,5 @@ -using NetMQ; +using MessagePack; +using NetMQ; using NetMQ.Sockets; using Newtonsoft.Json; using SHH.Contracts; // ★★★ 必须引用契约库 ★★★ @@ -77,9 +78,16 @@ public class StreamReceiverService : IDisposable // 3. 协议头校验 (Frame 0) if (msg[0].ConvertToString() != "SHH_V1") continue; + //// 4. 反序列化元数据 (Frame 1) + //string json = msg[1].ConvertToString(); + //var payload = JsonConvert.DeserializeObject(json); + // 4. 反序列化元数据 (Frame 1) - string json = msg[1].ConvertToString(); - var payload = JsonConvert.DeserializeObject(json); + // 直接获取二进制数据,不需要转 String (省去了 UTF8 解码开销) + byte[] metaBytes = msg[1].ToByteArray(); + + // 极速反序列化 + var payload = MessagePackSerializer.Deserialize(metaBytes); if (payload == null) continue; diff --git a/SHH.CameraDashboard/Services/CommandBusClient.cs b/SHH.CameraDashboard/Services/CommandBusClient.cs index d9e210b..acc272a 100644 --- a/SHH.CameraDashboard/Services/CommandBusClient.cs +++ b/SHH.CameraDashboard/Services/CommandBusClient.cs @@ -1,390 +1,158 @@ -using System; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Threading.Tasks; +using MessagePack; using NetMQ; using NetMQ.Sockets; -using Newtonsoft.Json; +using SHH.CameraDashboard.Services.Processors; using SHH.Contracts; +using System.Collections.Concurrent; +using System.Diagnostics; -namespace SHH.CameraDashboard.Services +namespace SHH.CameraDashboard { - /// - /// 客户端指令总线 (企业增强版) - /// 核心职责:作为指挥中心监听 7000 端口,管理所有网关连接。 - /// 通讯模式:Router (Bind) <--- Dealer (Connect) - /// 高级特性: - /// 1. 智能路由:根据 InstanceId 自动查找 NetMQ Identity。 - /// 2. QoS 分级:支持 "强一致性等待" 和 "射后不理" 两种模式。 - /// 3. 自动重试:网络超时自动重发,失败多次自动熔断。 - /// 4. 性能监控:精确统计全链路耗时 (RTT)。 - /// public class CommandBusClient : IDisposable { - #region --- 1. 字段与配置 --- - private RouterSocket? _routerSocket; private NetMQPoller? _poller; private volatile bool _isRunning; private readonly object _disposeLock = new object(); - // 默认超时设置 - private const int DEFAULT_TIMEOUT_MS = 2000; - private const int DEFAULT_MAX_RETRIES = 2; + // 单例模式 + public static CommandBusClient Instance { get; } = new CommandBusClient(); + + // 处理器字典 + private readonly Dictionary _processors = new(); - // ★★★ 核心:线程安全的任务字典 ★★★ - // Key: 请求ID (身份证号) - // Value: 异步任务凭证 (用于 await 唤醒) private readonly ConcurrentDictionary> _pendingRequests = new ConcurrentDictionary>(); - // ★★★ 核心:路由表 ★★★ - // Key: 实例ID (例如 "Gateway_01") - // Value: NetMQ 路由 Identity (二进制地址,这是 Router 发消息必须的“信封地址”) private readonly ConcurrentDictionary _sessions = new ConcurrentDictionary(); - /// - /// 当有服务端连上来并完成注册时触发 - /// - public event Action? OnServerRegistered; + // ★★★ 新增:拦截器管道 ★★★ + public InterceptorPipeline Pipeline { get; } = new InterceptorPipeline(); - #endregion + public event Action? OnServerRegistered; + public event Action>? OnDeviceStatusReport; + public event Action? OnCommandReceived; - #region --- 2. 启动与停止 --- + // 注册处理器的方法 + public void RegisterProcessor(IProtocolProcessor processor) + { + _processors[processor.ProtocolType] = processor; + } - /// - /// 启动指令中心监听 - /// - /// 监听端口 (建议 7000) public void Start(int port) { if (_isRunning) return; - - try - { - lock (_disposeLock) - { - _routerSocket = new RouterSocket(); - // 绑定端口,等待服务端(Active Mode)主动来连接 - // 使用 tcp://*:{port} 绑定本机所有网卡 - _routerSocket.Bind($"tcp://*:{port}"); - - // 注册接收事件 (基于 NetMQPoller 的异步非阻塞模式) - _routerSocket.ReceiveReady += OnReceiveReady; - - _poller = new NetMQPoller { _routerSocket }; - _poller.RunAsync(); // 在后台线程启动轮询 - - _isRunning = true; - Debug.WriteLine($"[ClientBus] 指令中心已启动,监听端口: {port}"); - } - } - catch (Exception ex) - { - // 启动失败属于致命错误,记录日志 - Debug.WriteLine($"[ClientBus-Error] 启动失败: {ex.Message}"); - throw; // 向上抛出,让 UI 层感知并报错 - } - } - - public void Stop() - { - if (!_isRunning) return; - lock (_disposeLock) { - _isRunning = false; - try - { - _poller?.Stop(); - _poller?.Dispose(); - _routerSocket?.Dispose(); - } - catch (Exception ex) - { - Debug.WriteLine($"[ClientBus-Error] 停止时异常: {ex.Message}"); - } - finally - { - // 彻底清理状态 - CleanupPendingTasks(); - _sessions.Clear(); - } + _routerSocket = new RouterSocket(); + _routerSocket.Bind($"tcp://*:{port}"); + _routerSocket.ReceiveReady += OnReceiveReady; + + // --- 注册处理器 --- + this.RegisterProcessor(new RegisterProcessor(this)); + this.RegisterProcessor(new StatusBatchProcessor(this)); + this.RegisterProcessor(new CommandResultProcessor(this)); + this.RegisterProcessor(new CommandProcessor(this)); + + _poller = new NetMQPoller { _routerSocket }; + _poller.RunAsync(); + _isRunning = true; } } - public void Dispose() + // 注意:NetMQ 的事件处理器本质上是同步的 (void)。 + // 为了调用异步拦截器,我们需要在这里使用 async void (仅限顶层事件处理) + private async void OnReceiveReady(object? sender, NetMQSocketEventArgs e) { - Stop(); - } - - private void CleanupPendingTasks() - { - // 取消所有挂起的请求,避免 SendAsync 里的 await 永久卡死 - foreach (var kvp in _pendingRequests) - { - kvp.Value.TrySetCanceled(); - } - _pendingRequests.Clear(); - } - - #endregion - - #region --- 3. 核心发送逻辑 (策略层) --- - - /// - /// 发送指令(包含 QoS判断 + 重试循环 + 熔断 + RTT统计) - /// - /// 目标网关ID (如 "Gateway_01") - /// 指令包 - /// 单次超时时间 (毫秒) - /// 最大重试次数 (0表示不重试) - /// 执行结果 - public async Task SendAsync(string instanceId, CommandPayload payload, int timeoutMs = DEFAULT_TIMEOUT_MS, int maxRetries = DEFAULT_MAX_RETRIES) - { - if (!_isRunning) return CommandResult.Fail("服务未启动"); - - // 1. 检查目标是否在线 (快速失败) - if (!_sessions.ContainsKey(instanceId)) - { - return CommandResult.Fail($"服务端 {instanceId} 离线或未连接"); - } - - // 2. 确保有 RequestId - if (string.IsNullOrEmpty(payload.RequestId)) - payload.RequestId = Guid.NewGuid().ToString("N"); - - // ========================================================= - // 策略 A: 射后不理 (Fire-and-Forget) - QoS 0 - // ========================================================= - // 适用于:心跳包、非关键日志、高频状态查询 - // 优势:不占用 await 线程资源,不产生网络拥堵 - if (!payload.RequireAck) - { - try - { - SendInternal(instanceId, payload); - return CommandResult.Ok("已投递 (NoAck Mode)"); - } - catch (Exception ex) - { - return CommandResult.Fail($"投递失败: {ex.Message}"); - } - } - - // ========================================================= - // 策略 B: 强一致性重试 (Reliable Retry) - QoS 1 - // ========================================================= - // 适用于:PTZ控制、录像启停、参数设置 - - int currentRetry = 0; - // 启动高精度计时器 (统计包含重试在内的总耗时) - Stopwatch totalStopwatch = Stopwatch.StartNew(); - - // 重试循环 (Retry Loop) - while (currentRetry <= maxRetries) - { - // 更新重试计数,服务端可据此判断是否需要打印 "Retry Warning" - payload.RetryCount = currentRetry; - - try - { - // ★ 核心原子操作:发送并等待单次结果 ★ - var result = await SendRequestCore(instanceId, payload, timeoutMs); - - // --- 成功路径 --- - totalStopwatch.Stop(); - result.ElapsedMilliseconds = totalStopwatch.Elapsed.TotalMilliseconds; - - // 如果重试过,打印一条恢复日志 - if (currentRetry > 0) - Debug.WriteLine($"[ClientBus] {payload.CmdCode} 在第 {currentRetry} 次重试后成功恢复。"); - - return result; - } - catch (TimeoutException) - { - // --- 超时路径 --- - Debug.WriteLine($"[ClientBus-Warn] Req {payload.RequestId} 超时 ({currentRetry + 1}/{maxRetries + 1})..."); - currentRetry++; - - // 可选:在重试前稍微等待一下 (指数退避),避免瞬间拥塞 - // await Task.Delay(50 * currentRetry); - } - catch (Exception ex) - { - // --- 致命错误路径 (如序列化失败、Socket已释放) --- - // 这种错误重试也没用,直接报错 - return CommandResult.Fail($"发送过程发生不可恢复错误: {ex.Message}"); - } - } - - // ========================================================= - // 熔断 (Meltdown) - // ========================================================= - totalStopwatch.Stop(); - var failRes = CommandResult.Fail($"请求熔断: 目标无响应 (已重试 {maxRetries} 次)"); - failRes.ElapsedMilliseconds = totalStopwatch.Elapsed.TotalMilliseconds; - return failRes; - } - - #endregion - - #region --- 4. 底层发送实现 (原子操作层) --- - - /// - /// 执行单次 "请求-响应" 周期 - /// - private async Task SendRequestCore(string instanceId, CommandPayload payload, int timeoutMs) - { - // 1. 创建异步凭证 (TCS) - // RunContinuationsAsynchronously 是必须的,防止 NetMQ 接收线程直接执行 await 后的 UI 代码导致死锁 - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - // 2. 注册到字典,等待回信 - // 如果 ID 冲突 (极低概率),说明上一个还没处理完,强行覆盖或报错 - _pendingRequests[payload.RequestId] = tcs; - - try - { - // 3. 发送网络包 - SendInternal(instanceId, payload); - - // 4. 异步等待 (Wait for TCS or Timeout) - // Task.WhenAny 是实现超时的经典模式 - var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); - - if (completedTask == tcs.Task) - { - // 任务完成 (OnReceiveReady 设置了结果) - return await tcs.Task; - } - else - { - // 时间到,任务还没完成 -> 抛出超时异常,触发外层重试 - throw new TimeoutException(); - } - } - finally - { - // 5. 清理现场 (无论成功失败,必须移除字典,防止内存泄漏) - _pendingRequests.TryRemove(payload.RequestId, out _); - } - } - - /// - /// 纯粹的 NetMQ 数据发送 (不处理逻辑) - /// - private void SendInternal(string instanceId, CommandPayload payload) - { - // 查路由表获取 Identity - if (_sessions.TryGetValue(instanceId, out byte[]? identity)) - { - var msg = new NetMQMessage(); - // Frame 1: 目标地址 (Identity) - msg.Append(identity); - // Frame 2: 数据 (JSON) - msg.Append(JsonConvert.SerializeObject(payload)); - - // 线程安全检查 - if (_routerSocket != null) - { - _routerSocket.SendMultipartMessage(msg); - } - } - else - { - throw new InvalidOperationException($"无法找到目标 {instanceId} 的路由信息"); - } - } - - #endregion - - #region --- 5. 核心接收逻辑 (Router) --- - - /// - /// 处理所有入站消息 - /// - private void OnReceiveReady(object? sender, NetMQSocketEventArgs e) - { - // 防止处理过程中崩溃导致监听停止 try { NetMQMessage msg = new NetMQMessage(); - // Router 模式:至少包含 [Identity, Data] 两帧,有时中间会有空帧 - if (!e.Socket.TryReceiveMultipartMessage(ref msg) || msg.FrameCount < 2) return; + // 1. 尝试接收多帧消息 + if (!e.Socket.TryReceiveMultipartMessage(ref msg)) return; - // 第一帧永远是发送方的 Identity - byte[] identity = msg[0].Buffer; - // 最后一帧通常是 JSON 数据 - string json = msg.Last.ConvertToString(); + // 2. 帧校验 (Router 收到 Dealer 消息:[Identity] [Protocol] [Data]) + // 此时 msg 应该有 3 帧 + if (msg.FrameCount < 3) return; - // 简单的协议识别 - // 优化建议:正式项目中可以用更严谨的 Header 区分,这里用 JSON 嗅探即可 - if (json.Contains("\"CmdCode\"")) - { - // ---> 收到注册包 (CmdCode 字段存在) - HandleRegistration(identity, json); - } - else if (json.Contains("\"Success\"")) - { - // ---> 收到回执包 (Success 字段存在) - HandleResponse(json); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[ClientBus-RecvError] 接收处理异常: {ex.Message}"); - } - } + byte[] identity = msg[0].Buffer; // Frame 0: 路由ID + string protocol = msg[1].ConvertToString(); // Frame 1: 协议标识 + byte[] rawData = msg[2].ToByteArray(); // Frame 2: 原始数据 - private void HandleRegistration(byte[] identity, string json) - { - try - { - var payload = JsonConvert.DeserializeObject(json); - if (payload?.CmdCode == "SERVER_REGISTER") + // ========================================================= + // ★★★ 核心改造 A: 接收拦截 (Inbound) ★★★ + // ========================================================= + // 执行管道处理 + var ctx = await Pipeline.ExecuteReceiveAsync(protocol, rawData); + + if (ctx != null) // 如果没被拦截 { - var regInfo = JsonConvert.DeserializeObject(payload.JsonParams); - if (regInfo != null) + // 使用处理后的协议和数据进行分发 + if (_processors.TryGetValue(ctx.Protocol, out var processor)) { - // 更新路由表:[实例名] -> [二进制地址] - _sessions[regInfo.InstanceId] = identity; - - Debug.WriteLine($"[ClientBus] 网关上线: {regInfo.InstanceId} IP: {regInfo.ServerIp}"); - - // 通知 UI 刷新列表 - OnServerRegistered?.Invoke(regInfo); + processor.Process(identity, ctx.Data); + } + else + { + Debug.WriteLine($"[Bus] 未知协议: {ctx.Protocol}"); } } } catch (Exception ex) { - Debug.WriteLine($"[ClientBus-Warn] 注册包解析失败: {ex.Message}"); + Debug.WriteLine($"[Bus-Err] {ex.Message}"); } } - private void HandleResponse(string json) - { - try - { - var result = JsonConvert.DeserializeObject(json); + // --- 供 Processor 调用的内部方法 (保持不变) --- + internal void UpdateSession(string instanceId, byte[] identity) => _sessions[instanceId] = identity; + internal void RaiseServerRegistered(RegisterPayload p) => OnServerRegistered?.Invoke(p); + internal void RaiseDeviceStatusReport(List i) => OnDeviceStatusReport?.Invoke(i); + internal void RaiseCommandReceived(CommandPayload payload) => OnCommandReceived?.Invoke(payload); - // 闭环匹配:根据 RequestId 找到挂起的 TCS - if (!string.IsNullOrEmpty(result?.RequestId) && - _pendingRequests.TryGetValue(result.RequestId, out var tcs)) + internal void HandleResponse(CommandResult result) + { + if (_pendingRequests.TryRemove(result.RequestId, out var tcs)) + tcs.TrySetResult(result); + } + + // ========================================================= + // ★★★ 核心改造 B: 发送拦截 (Outbound) ★★★ + // ========================================================= + // 改为 async Task 以支持异步拦截器 + public async Task SendInternalAsync(string instanceId, CommandPayload payload) + { + if (_sessions.TryGetValue(instanceId, out byte[]? identity)) + { + // 1. 序列化 + byte[] rawData = MessagePackSerializer.Serialize(payload); + + // 2. 执行管道处理 + var ctx = await Pipeline.ExecuteSendAsync(payload.Protocol, rawData); + + // 3. 发送 (如果没被拦截) + if (ctx != null && _routerSocket != null) { - // 设置结果 -> 唤醒 SendRequestCore -> 唤醒 SendAsync - tcs.TrySetResult(result); + // 注意:Socket 非线程安全,但 RouterSocket 的 SendMultipartMessage 通常是线程安全的 + // 或者通过 Poller 线程去发。但在 Router 模式下,多线程直接 Send 通常是允许的。 + var msg = new NetMQMessage(); + msg.Append(identity); + msg.Append(ctx.Protocol); // 使用拦截器处理后的 Protocol + msg.Append(ctx.Data); // 使用拦截器处理后的 Data + + _routerSocket.SendMultipartMessage(msg); } } - catch (Exception ex) - { - Debug.WriteLine($"[ClientBus-Warn] 回执包解析失败: {ex.Message}"); - } } - #endregion + public void Stop() + { + _isRunning = false; + _poller?.Stop(); + _poller?.Dispose(); + _routerSocket?.Dispose(); + } + + public void Dispose() => Stop(); } } \ No newline at end of file diff --git a/SHH.CameraDashboard/Services/Payloads/CommandProcessor.cs b/SHH.CameraDashboard/Services/Payloads/CommandProcessor.cs new file mode 100644 index 0000000..70a6a62 --- /dev/null +++ b/SHH.CameraDashboard/Services/Payloads/CommandProcessor.cs @@ -0,0 +1,41 @@ +using MessagePack; +using SHH.Contracts; +using System.Diagnostics; + +namespace SHH.CameraDashboard.Services.Processors +{ + /// + /// [协议处理器] 处理来自服务端的反向指令 (COMMAND) + /// 场景:服务端主动要求客户端执行某些动作(如弹出实时画面、同步系统配置等) + /// + public class CommandProcessor : IProtocolProcessor + { + public string ProtocolType => "COMMAND"; + private readonly CommandBusClient _bus; + + public CommandProcessor(CommandBusClient bus) + { + _bus = bus; + } + + public void Process(byte[] identity, byte[] payloadBytes) + { + try + { + // 1. 反序列化指令载体 + var payload = MessagePackSerializer.Deserialize(payloadBytes); + if (payload == null) return; + + // 2. 核心:触发总线上的指令接收事件 + // 让监听该事件的 ViewModel 或全局管理器去执行具体业务 + _bus.RaiseCommandReceived(payload); + + Debug.WriteLine($"[Bus] 收到服务端反向指令: {payload.CmdCode}, 目标: {payload.TargetId}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[Bus-Err] CommandProcessor 解析异常: {ex.Message}"); + } + } + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/Services/Payloads/CommandResultProcessor.cs b/SHH.CameraDashboard/Services/Payloads/CommandResultProcessor.cs new file mode 100644 index 0000000..bca3fe9 --- /dev/null +++ b/SHH.CameraDashboard/Services/Payloads/CommandResultProcessor.cs @@ -0,0 +1,17 @@ +using MessagePack; +using SHH.CameraDashboard; +using SHH.CameraDashboard.Services; +using SHH.Contracts; + +public class CommandResultProcessor : IProtocolProcessor +{ + public string ProtocolType => "COMMAND_RESULT"; + private readonly CommandBusClient _bus; + public CommandResultProcessor(CommandBusClient bus) => _bus = bus; + + public void Process(byte[] identity, byte[] payloadBytes) + { + var p = MessagePackSerializer.Deserialize(payloadBytes); + _bus.HandleResponse(p); + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/Services/Payloads/IProtocolProcessor.cs b/SHH.CameraDashboard/Services/Payloads/IProtocolProcessor.cs new file mode 100644 index 0000000..8749c11 --- /dev/null +++ b/SHH.CameraDashboard/Services/Payloads/IProtocolProcessor.cs @@ -0,0 +1,11 @@ +namespace SHH.CameraDashboard +{ + public interface IProtocolProcessor + { + // 匹配 Key(0) 的 Protocol 字符串 + string ProtocolType { get; } + + // 执行具体的解析与业务逻辑 + void Process(byte[] identity, byte[] payloadBytes); + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/Services/Payloads/RegisterProcessor.cs b/SHH.CameraDashboard/Services/Payloads/RegisterProcessor.cs new file mode 100644 index 0000000..e19fcaa --- /dev/null +++ b/SHH.CameraDashboard/Services/Payloads/RegisterProcessor.cs @@ -0,0 +1,20 @@ +using MessagePack; +using SHH.CameraDashboard; +using SHH.CameraDashboard.Services; +using SHH.Contracts; + +public class RegisterProcessor : IProtocolProcessor +{ + public string ProtocolType => ProtocolHeaders.ServerRegister; + + private readonly CommandBusClient _bus; + + public RegisterProcessor(CommandBusClient bus) => _bus = bus; + + public void Process(byte[] identity, byte[] payloadBytes) + { + var p = MessagePackSerializer.Deserialize(payloadBytes); + _bus.UpdateSession(p.InstanceId, identity); + _bus.RaiseServerRegistered(p); + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/Services/Payloads/StatusBatchProcessor.cs b/SHH.CameraDashboard/Services/Payloads/StatusBatchProcessor.cs new file mode 100644 index 0000000..ae44ef0 --- /dev/null +++ b/SHH.CameraDashboard/Services/Payloads/StatusBatchProcessor.cs @@ -0,0 +1,17 @@ +using MessagePack; +using SHH.CameraDashboard; +using SHH.CameraDashboard.Services; +using SHH.Contracts; + +public class StatusBatchProcessor : IProtocolProcessor +{ + public string ProtocolType => "STATUS_BATCH"; + private readonly CommandBusClient _bus; + public StatusBatchProcessor(CommandBusClient bus) => _bus = bus; + + public void Process(byte[] identity, byte[] payloadBytes) + { + var p = MessagePackSerializer.Deserialize(payloadBytes); + if (p?.Items != null) _bus.RaiseDeviceStatusReport(p.Items); + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/Services/ServerStateManager.cs b/SHH.CameraDashboard/Services/ServerStateManager.cs index fc6834a..fa3e203 100644 --- a/SHH.CameraDashboard/Services/ServerStateManager.cs +++ b/SHH.CameraDashboard/Services/ServerStateManager.cs @@ -17,7 +17,7 @@ namespace SHH.CameraDashboard /// /// 处理注册/心跳包,更新列表 /// - public void RegisterOrUpdate(ServerRegistrationDto info) + public void RegisterOrUpdate(RegisterPayload info) { // 确保在 UI 线程执行 (WPF 必须) Application.Current.Dispatcher.Invoke(() => diff --git a/SHH.CameraDashboard/ViewModels/CameraImageSubscriptionViewModels.cs b/SHH.CameraDashboard/ViewModels/CameraImageSubscriptionViewModels.cs index 7f1b344..cc66a9a 100644 --- a/SHH.CameraDashboard/ViewModels/CameraImageSubscriptionViewModels.cs +++ b/SHH.CameraDashboard/ViewModels/CameraImageSubscriptionViewModels.cs @@ -15,11 +15,12 @@ namespace SHH.CameraDashboard // 用于绑定 ComboBox 的类型列表 public Dictionary SubscriptionTypes { get; } = new Dictionary { - { 0, "本地窗口预览" }, - { 1, "本地录像" }, - { 2, "句柄渲染 (嵌入)" }, - { 3, "网络转发 (TCP/UDP)" }, - { 4, "Web 推流" } + { 0, "仅取流" }, + { 1, "本地窗口预览" }, + { 2, "本地录像" }, + { 3, "句柄渲染 (嵌入)" }, + { 4, "网络转发 (TCP/UDP)" }, + { 5, "Web 推流" } }; // --- 数据源 --- diff --git a/SHH.CameraSdk/Core/Manager/CameraManager.cs b/SHH.CameraSdk/Core/Manager/CameraManager.cs index 02dbfe7..e2fad47 100644 --- a/SHH.CameraSdk/Core/Manager/CameraManager.cs +++ b/SHH.CameraSdk/Core/Manager/CameraManager.cs @@ -437,4 +437,25 @@ public class CameraManager : IDisposable, IAsyncDisposable // 复用现有的 GetAllDevices 逻辑 return GetAllDevices(); } + + #region --- [新增] 状态事件总线 (SDK 对外接口) --- + + /// + /// 当设备在线/离线状态发生变更时触发 + /// 参数1: DeviceId + /// 参数2: IsOnline (true=在线, false=离线) + /// 参数3: Reason (变更原因) + /// + public event Action? OnDeviceStatusChanged; + + /// + /// [内部方法] 供 Sentinel 调用,触发事件冒泡 + /// + internal void NotifyStatusChange(long deviceId, bool isOnline, string reason) + { + // 仅仅是触发 C# 事件,完全不知道网络发送的存在 + OnDeviceStatusChanged?.Invoke(deviceId, isOnline, reason); + } + + #endregion } \ No newline at end of file diff --git a/SHH.CameraSdk/Core/Services/ConnectivitySentinel.cs b/SHH.CameraSdk/Core/Services/ConnectivitySentinel.cs index 25e7165..2e94728 100644 --- a/SHH.CameraSdk/Core/Services/ConnectivitySentinel.cs +++ b/SHH.CameraSdk/Core/Services/ConnectivitySentinel.cs @@ -1,4 +1,5 @@ -using System.Net.NetworkInformation; +using System.Drawing; +using System.Net.NetworkInformation; namespace SHH.CameraSdk; @@ -15,6 +16,11 @@ public class ConnectivitySentinel private readonly PeriodicTimer _timer; private readonly CancellationTokenSource _cts = new(); + // [关键] 状态缓存:用于“去重”。 + // 只有当状态真的从 true 变 false (或反之) 时,才通知 Manager。 + // 防止每 3 秒发一次 "在线" 骚扰上层。 + private readonly ConcurrentDictionary _lastStates = new(); + // [关键配置] 最大并发度 // 建议值:CPU 核心数 * 4,或者固定 16-32 // 50 个摄像头,设为 16,意味着分 4 批完成,总耗时极短 @@ -77,6 +83,21 @@ public class ConnectivitySentinel // [状态注入]:将探测结果“注入”回设备 device.SetNetworkStatus(isAlive); + + // 3. [状态去重与上报] + // 获取上一次的状态,如果没记录过,假设它之前是反状态(强制第一次上报) + bool lastState = _lastStates.TryGetValue(device.Id, out bool val) ? val : !isAlive; + + if (lastState != isAlive) + { + // 记录新状态 + _lastStates[device.Id] = isAlive; + + // ★★★ 核心动作:只通知 Manager,不做任何网络操作 ★★★ + _manager.NotifyStatusChange(device.Id, isAlive, "网络连通性哨兵检测结论"); + + // Console.WriteLine($"[Sentinel] 诊断变化: {device.Id} -> {isAlive}"); + } } // 纯粹的 Ping 逻辑 diff --git a/SHH.CameraService/Core/CmdClients/CommandClientWorker.cs b/SHH.CameraService/Core/CmdClients/CommandClientWorker.cs index c1d36ba..cd13ef0 100644 --- a/SHH.CameraService/Core/CmdClients/CommandClientWorker.cs +++ b/SHH.CameraService/Core/CmdClients/CommandClientWorker.cs @@ -1,8 +1,9 @@ -using Microsoft.Extensions.Hosting; +using MessagePack; +using Microsoft.Extensions.Hosting; using NetMQ; using NetMQ.Sockets; -using Newtonsoft.Json; using SHH.CameraSdk; +using SHH.Contracts; using System.Text; namespace SHH.CameraService; @@ -10,130 +11,144 @@ namespace SHH.CameraService; public class CommandClientWorker : BackgroundService { private readonly ServiceConfig _config; - private readonly CommandDispatcher _dispatcher; // 注入分发器 + private readonly CommandDispatcher _dispatcher; - public CommandClientWorker(ServiceConfig config, CommandDispatcher dispatcher) + // ★ 1. 注入拦截器管道管理器 + private readonly InterceptorPipeline _pipeline; + + public CommandClientWorker( + ServiceConfig config, + CommandDispatcher dispatcher, + InterceptorPipeline pipeline) // <--- 注入 { _config = config; _dispatcher = dispatcher; + _pipeline = pipeline; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - // ================================================================= - // ★★★ 核心修复:强制让出主线程 ★★★ - // 这行代码会让当前的 ExecuteAsync 立即返回一个未完成的 Task 给 Host, - // Host 就会认为 "这个服务启动好了",然后继续去启动 WebAPI。 - // 而剩下的代码会被调度到线程池里异步执行,互不干扰。 - // ================================================================= await Task.Yield(); - // 1. 如果不是主动/混合模式,不需要连接 if (!_config.ShouldConnect) return; + if (_config.CommandEndpoints.Count == 0) return; - var cmdEndpoints = _config.CommandEndpoints; - if (cmdEndpoints.Count == 0) - { - Console.WriteLine("[指令] 未配置指令通道,跳过注册。"); - return; - } - - // 2. 初始化 Dealer Socket using var dealer = new DealerSocket(); - - // ★★★ 关键:设置身份标识 (Identity) ★★★ - // 服务端 (Router) 收到消息时,第一帧就是这个 ID - // 如果不设,ZMQ 会随机生成一个二进制 ID,服务端就不知道你是谁了 string myIdentity = _config.AppId; dealer.Options.Identity = Encoding.UTF8.GetBytes(myIdentity); - // 3. 连接所有目标 (遍历 ServiceEndpoint 对象) - foreach (var ep in cmdEndpoints) + foreach (var ep in _config.CommandEndpoints) { - Console.WriteLine($"[指令] 连接控制端: {ep.Uri} [{ep.Description}]"); - try - { - dealer.Connect(ep.Uri); - } - catch (Exception ex) - { - Console.WriteLine($"[指令] 连接失败 {ep.Uri}: {ex.Message}"); - } + try { dealer.Connect(ep.Uri); } + catch (Exception ex) { Console.WriteLine($"[指令] 连接失败 {ep.Uri}: {ex.Message}"); } } - // 1. 获取本机 IP (简单的获取方式,用于上报给 Dashboard) string localIp = "127.0.0.1"; - try + // ... (获取 IP 代码省略,保持不变) ... + + // ================================================================= + // 构建注册包 + // ================================================================= + var registerPayload = new RegisterPayload { - // 简单获取首个非回环 IP,生产环境建议用更严谨的帮助类 - var host = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName()); - localIp = host.AddressList.FirstOrDefault(ip => - ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)?.ToString() ?? "127.0.0.1"; - } - catch { } - - // 4. 构建注册/登录包 - var registerPayload = new - { - Action = "Register", - Payload = new - { - // 1. AppId (身份) - Id = _config.AppId, - - // 2. Version (程序集版本) - Version = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "1.0.0", - - // 3. 进程 ID (用于远程监控) - Pid = Environment.ProcessId, - - // 4. 关键端口信息 - // 告诉 Dashboard:如果你想调我的 REST API,请访问这个端口 - WebPort = _config.BasePort, - - // 如果您有本地绑定的 ZMQ 端口也可以在这里上报 - // VideoPort = _config.BasePort + 1, - - // 基础网络信息 - Ip = localIp, - - // 附带信息:我是要把视频推给谁 (供 Dashboard 调试用) - TargetVideoNodes = _config.VideoEndpoints.Select(e => e.Uri).ToList() - }, - Time = DateTime.Now + Protocol = ProtocolHeaders.ServerRegister, + InstanceId = _config.AppId, + ProcessId = Environment.ProcessId, + Version = "1.0.0", + ServerIp = localIp, + WebApiPort = _config.BasePort, + StartTime = DateTime.Now }; - string json = JsonConvert.SerializeObject(registerPayload); + try + { + byte[] regData = MessagePackSerializer.Serialize(registerPayload); - // 5. 发送注册包 - // Dealer 连接建立是异步的,所以这里直接发,ZMQ 会在底层连接成功后自动把消息推出去 - // 为了保险,对于多个 Endpoint,Dealer 默认是负载均衡发送的(轮询)。 - // 如果想让每个 Endpoint 都收到注册包,这在 Dealer 模式下稍微有点特殊。 - // 但通常我们只需要发一次,只要有一个 Dashboard 收到并建立会话即可。 - // 或者简单粗暴:循环发送几次,确保覆盖。 + // ============================================================= + // ★ 2. 拦截点 A: 发送注册包 (Outbound) + // ============================================================= + var ctx = await _pipeline.ExecuteSendAsync(ProtocolHeaders.ServerRegister, regData); - Console.WriteLine($"[指令] 发送注册包: {json}"); - dealer.SendFrame(json); + if (ctx != null) // 如果未被拦截 + { + // 注意:这里使用 ctx.Protocol 和 ctx.Data,允许拦截器修改内容 + dealer.SendMoreFrame(ctx.Protocol) + .SendFrame(ctx.Data); - // 6. 进入监听循环 (等待 ACK 或 指令) - // 进入监听循环 - while (!stoppingToken.IsCancellationRequested) + Console.WriteLine($"[指令] 注册包已发送 ({ctx.Data.Length} bytes)"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[致命错误] 注册流程异常: {ex.Message}"); + return; + } + + // ================================================================= + // 定义 ACK 发送逻辑 (包含拦截器) + // ================================================================= + // 注意:这里需要 async,因为拦截器是异步的 + Action sendAckHandler = async (result) => { try { - if (dealer.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string msg)) - { - Console.WriteLine($"[指令] 收到消息: {msg}"); + byte[] resultBytes = MessagePackSerializer.Serialize(result); - // ★★★ 核心变化:直接扔给分发器 ★★★ - // 无论未来加多少指令,这里都不用改代码 - await _dispatcher.DispatchAsync(msg); + // ========================================================= + // ★ 3. 拦截点 B: 发送 ACK 回执 (Outbound) + // ========================================================= + // 协议头是 COMMAND_RESULT + var ctx = await _pipeline.ExecuteSendAsync(ProtocolHeaders.CommandResult, resultBytes); + + if (ctx != null) + { + dealer.SendMoreFrame(ctx.Protocol) + .SendFrame(ctx.Data); + + Console.WriteLine($"[指令] 已回复 ACK -> Req: {result.RequestId}"); } } catch (Exception ex) { - Console.WriteLine($"[指令] 异常: {ex.Message}"); + Console.WriteLine($"[ACK Error] 回执发送失败: {ex.Message}"); } + }; + + // 订阅事件 (需要适配 async void,注意异常捕获) + _dispatcher.OnResponseReady += async (res) => await Task.Run(() => sendAckHandler(res)); + + // ================================================================= + // 接收循环 + // ================================================================= + try + { + while (!stoppingToken.IsCancellationRequested) + { + NetMQMessage incomingMsg = new NetMQMessage(); + if (dealer.TryReceiveMultipartMessage(TimeSpan.FromMilliseconds(500), ref incomingMsg)) + { + if (incomingMsg.FrameCount >= 2) + { + string rawProtocol = incomingMsg[0].ConvertToString(); + byte[] rawData = incomingMsg[1].ToByteArray(); + + // ================================================= + // ★ 4. 拦截点 C: 接收指令 (Inbound) + // ================================================= + var ctx = await _pipeline.ExecuteReceiveAsync(rawProtocol, rawData); + + if (ctx != null) // 如果未被拦截 + { + // 将处理后的数据交给 Dispatcher + await _dispatcher.DispatchAsync(ctx.Protocol, ctx.Data); + } + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[指令] 接收循环异常: {ex.Message}"); } } } \ No newline at end of file diff --git a/SHH.CameraService/Core/CmdClients/CommandDispatcher.cs b/SHH.CameraService/Core/CmdClients/CommandDispatcher.cs index bf895ba..58e6c55 100644 --- a/SHH.CameraService/Core/CmdClients/CommandDispatcher.cs +++ b/SHH.CameraService/Core/CmdClients/CommandDispatcher.cs @@ -1,46 +1,91 @@ -using Newtonsoft.Json.Linq; +// 文件: Core\CmdClients\CommandDispatcher.cs + +using MessagePack; +using Newtonsoft.Json.Linq; +using SHH.Contracts; +using System.Text; namespace SHH.CameraService; public class CommandDispatcher { - // 路由表:Key = ActionName, Value = Handler + // 1. 注入路由表 private readonly Dictionary _handlers; - // 通过依赖注入拿到所有实现了 ICommandHandler 的类 + // 2. 定义回执事件 (ACK闭环的核心) + public event Action? OnResponseReady; + + // 3. 构造函数:注入所有 Handler public CommandDispatcher(IEnumerable handlers) { - _handlers = handlers.ToDictionary(h => h.ActionName, h => h); + // 将注入的 Handler 转换为字典,Key = ActionName (e.g. "SyncCamera") + _handlers = handlers.ToDictionary(h => h.ActionName, h => h, StringComparer.OrdinalIgnoreCase); } - public async Task DispatchAsync(string jsonMessage) + public async Task DispatchAsync(string protocol, byte[] data) { try { - var jObj = JObject.Parse(jsonMessage); - string action = jObj["Action"]?.ToString(); - var payload = jObj["Payload"]; + // 只处理 COMMAND 协议 + if (protocol != ProtocolHeaders.Command) return; - if (string.IsNullOrEmpty(action)) return; + // 反序列化信封 + var envelope = MessagePackSerializer.Deserialize(data); + if (envelope == null) return; - // 1. 查找是否有对应的处理器 - if (_handlers.TryGetValue(action, out var handler)) + string cmdCode = envelope.CmdCode; // e.g. "SyncCamera" + Console.WriteLine($"[分发] 收到指令: {cmdCode} (ID: {envelope.RequestId})"); + + bool isSuccess = true; + string message = "OK"; + + // --- 路由匹配逻辑 --- + if (_handlers.TryGetValue(cmdCode, out var handler)) { - await handler.ExecuteAsync(payload); - } - else if (action == "ACK") - { - // ACK 是特殊的,可以直接在这里处理或者忽略 - Console.WriteLine($"[指令] 握手成功: {jObj["Message"]}"); + try + { + // 数据适配:你的 Handler 需要 JToken + // 如果 envelope.JsonParams 是空的,传个空对象防止报错 + var jsonStr = string.IsNullOrEmpty(envelope.JsonParams) ? "{}" : envelope.JsonParams; + var token = JToken.Parse(jsonStr); + + // ★★★ 核心:调用 SyncCameraHandler.ExecuteAsync ★★★ + await handler.ExecuteAsync(token); + + message = $"Executed {cmdCode}"; + } + catch (Exception ex) + { + isSuccess = false; + message = $"Handler Error: {ex.Message}"; + Console.WriteLine($"[业务异常] {message}"); + } } else { - Console.WriteLine($"[警告] 未知的指令: {action}"); + isSuccess = false; + message = $"No handler found for {cmdCode}"; + Console.WriteLine($"[警告] {message}"); + } + + // --- ACK 闭环逻辑 --- + if (envelope.RequireAck) + { + var result = new CommandResult + { + Protocol = ProtocolHeaders.CommandResult, + RequestId = envelope.RequestId, // 必须带回 ID + Success = isSuccess, + Message = message, + Timestamp = DateTime.Now.Ticks + }; + // 触发事件 + OnResponseReady?.Invoke(result); } } catch (Exception ex) { - Console.WriteLine($"[分发错误] {ex.Message}"); + Console.WriteLine($"[Dispatcher] 致命错误: {ex.Message}"); } } } \ No newline at end of file diff --git a/SHH.CameraService/Core/CmdClients/SyncCameraHandler.cs b/SHH.CameraService/Core/CmdClients/SyncCameraHandler.cs index c8be4b1..59a8dbc 100644 --- a/SHH.CameraService/Core/CmdClients/SyncCameraHandler.cs +++ b/SHH.CameraService/Core/CmdClients/SyncCameraHandler.cs @@ -8,7 +8,7 @@ public class SyncCameraHandler : ICommandHandler { private readonly CameraManager _cameraManager; - public string ActionName => "SyncCamera"; + public string ActionName => ProtocolHeaders.SyncCamera; public SyncCameraHandler(CameraManager cameraManager) { diff --git a/SHH.CameraService/Core/JsonHelper.cs b/SHH.CameraService/Core/JsonHelper.cs new file mode 100644 index 0000000..03cfb2a --- /dev/null +++ b/SHH.CameraService/Core/JsonHelper.cs @@ -0,0 +1,109 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace SHH.CameraService +{ + /// + /// JSON 序列化与反序列化帮助类 + /// 职责: + /// 1. 提供全局统一的 JSON 转换配置(如驼峰命名、日期格式)。 + /// 2. 封装常见的序列化和反序列化操作。 + /// 3. 增加对 null 输入和无效 JSON 的健壮性处理。 + /// + public static class JsonHelper + { + #region --- 静态配置 --- + + /// + /// 全局共享的 JSON 序列化设置。 + /// 静态构造函数保证其只被初始化一次。 + /// + private static readonly JsonSerializerSettings _settings; + + #endregion + + #region --- 静态构造函数 --- + + /// + /// 静态构造函数,用于初始化全局的 JSON 序列化设置。 + /// + static JsonHelper() + { + _settings = new JsonSerializerSettings + { + // 1. 命名策略:将 C# 的 PascalCase 属性名序列化为 JSON 的 camelCase。 + // 这是与 JavaScript/TypeScript 前端交互的标准做法。 + ContractResolver = new CamelCasePropertyNamesContractResolver(), + + // 2. 日期格式:统一使用 "yyyy-MM-dd HH:mm:ss" 格式,避免时区和格式差异导致的问题。 + DateFormatString = "yyyy-MM-dd HH:mm:ss", + + // 3. Null 值处理:在序列化时忽略值为 null 的属性。 + // 这可以显著减小 JSON 字符串的大小,并使生成的 JSON 更干净。 + // 例如,`{ Name = "Alice", Age = null }` 会被序列化为 `{"name":"Alice"}`。 + NullValueHandling = NullValueHandling.Ignore + }; + + // 4. 枚举转换:将枚举值序列化为其字符串表示,而不是数字。 + // 例如,`LogLevel.Info` 会被序列化为 `"info"`,而不是 `1`。 + _settings.Converters.Add(new StringEnumConverter()); + } + + #endregion + + #region --- 公共方法 --- + + /// + /// 将对象序列化为 JSON 字符串。 + /// + /// 要序列化的对象。 + /// 序列化后的 JSON 字符串。如果输入为 null,则返回空字符串。 + public static string Serialize(object obj) + { + // [健壮性] 如果输入对象为 null,返回空字符串,而不是 "null"。 + // 这可以防止在创建 HTTP 请求内容时出现意外行为。 + if (obj == null) + { + return string.Empty; + } + + return JsonConvert.SerializeObject(obj, _settings); + } + + /// + /// 将 JSON 字符串反序列化为指定类型的对象。 + /// + /// 目标对象的类型(必须是引用类型)。 + /// 要反序列化的 JSON 字符串。 + /// 成功时返回反序列化后的对象;失败或输入无效时返回 null。 + public static T? Deserialize(string json) where T : class + { + // [健壮性] 检查输入是否为 null、空字符串或仅包含空白字符。 + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + // [健壮性] 处理 JSON 字符串为 "null" 的特殊情况。 + if (json.Trim() == "null") + { + return null; + } + + try + { + // 尝试使用预配置的设置进行反序列化。 + return JsonConvert.DeserializeObject(json, _settings); + } + catch (JsonException) + { + // [健壮性] 如果 JSON 格式无效,捕获异常并返回 null。 + // 这可以防止程序因一个格式错误的 JSON 字符串而崩溃。 + return null; + } + } + + #endregion + } +} \ No newline at end of file diff --git a/SHH.CameraService/Core/NetSenders/DeviceStateMonitorWorker.cs b/SHH.CameraService/Core/NetSenders/DeviceStateMonitorWorker.cs new file mode 100644 index 0000000..8b108c3 --- /dev/null +++ b/SHH.CameraService/Core/NetSenders/DeviceStateMonitorWorker.cs @@ -0,0 +1,151 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Hosting; +using NetMQ; +using NetMQ.Sockets; +using MessagePack; +using SHH.CameraSdk; +using SHH.Contracts; + +namespace SHH.CameraService +{ + /// + /// [二合一] 设备状态聚合与上报服务 + /// + public class DeviceStateMonitorWorker : BackgroundService + { + private readonly CameraManager _manager; + private readonly ServiceConfig _config; + + // ★ 2. 注入拦截器管道 + private readonly InterceptorPipeline _pipeline; + + // 本地状态全集缓存 + private readonly ConcurrentDictionary _stateStore = new(); + + // 标记是否有新变更 + private volatile bool _isDirty = false; + private long _lastSendTick = 0; + + // ★ 3. 构造函数增加 InterceptorPipeline 参数 + public DeviceStateMonitorWorker( + CameraManager manager, + ServiceConfig config, + InterceptorPipeline pipeline) // <--- 注入点 + { + _manager = manager; + _config = config; + _pipeline = pipeline; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // 1. 初始化缓存 (默认离线) + foreach (var dev in _manager.GetAllDevices()) + { + UpdateLocalState(dev.Id, false, "Init"); + } + + // 2. 挂载 SDK 事件 + _manager.OnDeviceStatusChanged += OnSdkStatusChanged; + + // 3. 建立连接 + var cmdEndpoint = _config.CommandEndpoints.FirstOrDefault()?.Uri; + if (string.IsNullOrEmpty(cmdEndpoint)) + { + Console.WriteLine("[StatusWorker] 警告: 未配置 Command 端点,状态上报无法启动。"); + return; + } + + Console.WriteLine($"[StatusWorker] 启动状态上报,直连服务端: {cmdEndpoint}"); + + using var socket = new DealerSocket(); + socket.Options.SendHighWatermark = 1000; + // 设置 Identity 是个好习惯,虽然这里只发不收 + // socket.Options.Identity = ... + socket.Connect(cmdEndpoint); + + // 4. 定时循环 (1秒1次) + var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + // ★ 4. 关键修正:必须使用 await 调用新的异步方法 + await CheckAndDirectSendAsync(socket); + } + } + finally + { + _manager.OnDeviceStatusChanged -= OnSdkStatusChanged; + socket.Dispose(); + } + } + + private void OnSdkStatusChanged(long deviceId, bool isOnline, string reason) + { + UpdateLocalState(deviceId, isOnline, reason); + _isDirty = true; + } + + private void UpdateLocalState(long deviceId, bool isOnline, string reason) + { + var evt = new StatusEventPayload + { + CameraId = deviceId.ToString(), + IsOnline = isOnline, + Reason = reason, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + _stateStore[deviceId.ToString()] = evt; + } + + /// + /// 检查并在当前线程直接发送 (已改为异步 Task) + /// + // ★ 5. 关键修正:void -> async Task + private async Task CheckAndDirectSendAsync(NetMQSocket socket) + { + long now = Environment.TickCount64; + // 策略: 有变更 或 超过5秒(心跳) + bool shouldSend = _isDirty || (now - _lastSendTick > 5000); + + if (shouldSend) + { + try + { + // A. 组包 (全量) + var snapshot = _stateStore.Values.ToList(); + var batch = new StatusBatchPayload + { + Items = snapshot, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + // B. 序列化 + byte[] data = MessagePackSerializer.Serialize(batch); + + // ========================================================= + // ★ 6. 拦截器调用 + // ========================================================= + // 这里的 "STATUS_BATCH" 是协议头,你可以替换为 ProtocolHeaders.StatusBatch (如果定义了的话) + var ctx = await _pipeline.ExecuteSendAsync("STATUS_BATCH", data); + + if (ctx != null) // 如果没被拦截 + { + // C. 直接发送 + socket.SendMoreFrame(ctx.Protocol) + .SendFrame(ctx.Data); + + // D. 重置标记 + _isDirty = false; + _lastSendTick = now; + } + } + catch (Exception ex) + { + Console.WriteLine($"[StatusWorker] 发送失败: {ex.Message}"); + } + } + } + } +} \ No newline at end of file diff --git a/SHH.CameraService/Core/NetSenders/NetMQProtocolExtensions.cs b/SHH.CameraService/Core/NetSenders/NetMQProtocolExtensions.cs index e378638..f6d70f0 100644 --- a/SHH.CameraService/Core/NetSenders/NetMQProtocolExtensions.cs +++ b/SHH.CameraService/Core/NetSenders/NetMQProtocolExtensions.cs @@ -1,4 +1,5 @@ -using NetMQ; +using MessagePack; +using NetMQ; using SHH.Contracts; namespace SHH.CameraService @@ -21,8 +22,16 @@ namespace SHH.CameraService // Frame 0: 协议魔数 msg.Append(PROTOCOL_HEADER); - // Frame 1: 元数据 JSON - msg.Append(payload.GetMetadataJson()); + ////// Frame 1: 元数据 JSON + ////msg.Append(payload.GetMetadataJson()); + + // ★★★ 修复点:在序列化之前,手动更新 Payload 的标志位 ★★★ + payload.HasOriginalImage = (payload.OriginalImageBytes != null && payload.OriginalImageBytes.Length > 0); + payload.HasTargetImage = (payload.TargetImageBytes != null && payload.TargetImageBytes.Length > 0); + + // Frame 1: Metadata (MessagePack) + byte[] metaBytes = MessagePackSerializer.Serialize(payload); + msg.Append(metaBytes); // Frame 2: 原始图 (保持帧位对齐,无数据则发空帧) if (payload.HasOriginalImage && payload.OriginalImageBytes != null) @@ -49,9 +58,14 @@ namespace SHH.CameraService // Frame 0 Check if (msg[0].ConvertToString() != PROTOCOL_HEADER) return null; - // Frame 1: Metadata - string json = msg[1].ConvertToString(); - var payload = VideoPayload.FromMetadataJson(json); + //// Frame 1: Metadata + //string json = msg[1].ConvertToString(); + //var payload = VideoPayload.FromMetadataJson(json); + + // [新代码] 直接从二进制还原 + // ToByteArray() 虽然会产生一次拷贝,但对于 Metadata 这种小数据影响微乎其微 + // 相比 JSON 解析 String 的开销,这已经非常快了 + var payload = MessagePackSerializer.Deserialize(msg[1].ToByteArray()); if (payload == null) return null; // Frame 2: Raw Image diff --git a/SHH.CameraService/Core/NetSenders/NetworkStreamingWorker.cs b/SHH.CameraService/Core/NetSenders/NetworkStreamingWorker.cs index a998dac..0fa6466 100644 --- a/SHH.CameraService/Core/NetSenders/NetworkStreamingWorker.cs +++ b/SHH.CameraService/Core/NetSenders/NetworkStreamingWorker.cs @@ -92,6 +92,9 @@ public class NetworkStreamingWorker : BackgroundService DispatchTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + // 添加订阅者 + payload.SubscriberIds.AddRange(frame.SubscriberIds); + // 计算转码耗时(ms) double processMs = (Stopwatch.GetTimestamp() - startTick) * 1000.0 / Stopwatch.Frequency; payload.Diagnostics["encode_ms"] = Math.Round(processMs, 2); diff --git a/SHH.CameraService/Interceptors/InterceptorPipeline.cs b/SHH.CameraService/Interceptors/InterceptorPipeline.cs new file mode 100644 index 0000000..d4c4688 --- /dev/null +++ b/SHH.CameraService/Interceptors/InterceptorPipeline.cs @@ -0,0 +1,45 @@ +namespace SHH.CameraService; + +public class InterceptorPipeline +{ + private readonly IEnumerable _interceptors; + + // 通过依赖注入获取所有注册的拦截器 + public InterceptorPipeline(IEnumerable interceptors) + { + _interceptors = interceptors; + } + + /// + /// 执行发送管道 + /// + /// 返回处理后的上下文,如果被拦截则返回 null + public async Task ExecuteSendAsync(string protocol, byte[] data) + { + var context = new ProtocolContext(protocol, data); + + foreach (var interceptor in _interceptors) + { + await interceptor.OnSendingAsync(context); + if (context.IsBlocked) return null; // 被拦截,终止发送 + } + + return context; + } + + /// + /// 执行接收管道 + /// + public async Task ExecuteReceiveAsync(string protocol, byte[] data) + { + var context = new ProtocolContext(protocol, data); + + foreach (var interceptor in _interceptors) + { + await interceptor.OnReceivedAsync(context); + if (context.IsBlocked) return null; // 被拦截,丢弃消息 + } + + return context; + } +} \ No newline at end of file diff --git a/SHH.CameraService/Interceptors/ProtocolContext.cs b/SHH.CameraService/Interceptors/ProtocolContext.cs new file mode 100644 index 0000000..b765af1 --- /dev/null +++ b/SHH.CameraService/Interceptors/ProtocolContext.cs @@ -0,0 +1,37 @@ +namespace SHH.CameraService; + +/// +/// 协议上下文 (用于在拦截器之间传递数据) +/// +public class ProtocolContext +{ + public string Protocol { get; set; } + public byte[] Data { get; set; } + + /// + /// 是否拦截/终止 (设为 true 则不再继续传递) + /// + public bool IsBlocked { get; set; } = false; + + public ProtocolContext(string protocol, byte[] data) + { + Protocol = protocol; + Data = data; + } +} + +/// +/// 拦截器接口 +/// +public interface IProtocolInterceptor +{ + /// + /// 发送前触发 (Outbound) + /// + Task OnSendingAsync(ProtocolContext context); + + /// + /// 接收后触发 (Inbound) + /// + Task OnReceivedAsync(ProtocolContext context); +} \ No newline at end of file diff --git a/SHH.CameraService/Program.cs b/SHH.CameraService/Program.cs index f03545a..e5113a2 100644 --- a/SHH.CameraService/Program.cs +++ b/SHH.CameraService/Program.cs @@ -41,6 +41,7 @@ public class Program // 注册后台引擎 (理由:托管长周期的硬件状态监控) builder.Services.AddHostedService(); + builder.Services.AddHostedService(); // 配置 Web 相关的服务 ConfigureWebServices(builder, config); @@ -70,7 +71,7 @@ public class Program // 3. 注册采集者 (它会注入上面的 targets,进行编码和分发) builder.Services.AddHostedService(); - // 4. 为每个 Target 注册一个独立的发送者 + // 5. 为每个 Target 注册一个独立的发送者 foreach (var target in netTargets) { builder.Services.AddHostedService(sp => new NetMqSenderWorker(target)); @@ -80,6 +81,9 @@ public class Program // 5. 命令管道配置 // ============================================================= + // 2. 注册管道管理器 + builder.Services.AddSingleton(); + // 负责连接 Dashboard,注册身份,接收重启/控制指令 builder.Services.AddHostedService(); diff --git a/SHH.Contracts/Commands/CameraConfigDto.cs b/SHH.Contracts/CameraConfigDto.cs similarity index 100% rename from SHH.Contracts/Commands/CameraConfigDto.cs rename to SHH.Contracts/CameraConfigDto.cs diff --git a/SHH.Contracts/CommandResult.cs b/SHH.Contracts/CommandResult.cs index 3e7c313..971bffc 100644 --- a/SHH.Contracts/CommandResult.cs +++ b/SHH.Contracts/CommandResult.cs @@ -1,16 +1,27 @@ -namespace SHH.Contracts +using MessagePack; + +namespace SHH.Contracts { /// /// 通用指令执行结果 (Response) /// + [MessagePackObject] public class CommandResult { + #region --- 0. 协议自描述 --- + + [Key(0)] + public string Protocol { get; set; } = "COMMAND_RESULT"; + + #endregion + #region --- 核心匹配信息 --- /// /// 回执 ID (必须与请求包的 RequestId 一致) /// 客户端靠这个 ID 来找到对应的 await Task /// + [Key(1)] public string RequestId { get; set; } #endregion @@ -20,17 +31,20 @@ /// /// 执行是否成功 /// + [Key(2)] public bool Success { get; set; } /// /// 结果消息 (成功提示或错误原因) /// + [Key(3)] public string Message { get; set; } /// /// 返回的数据 (JSON 或 Base64 字符串) /// 示例: 截图的 Base64,或者查询到的设备列表 JSON /// + [Key(4)] public string Data { get; set; } #endregion @@ -42,10 +56,17 @@ /// 从客户端发出指令,到收到服务端回执的总时长 /// 注意:该字段由客户端收到回执后自动计算填充,服务端不需要赋值 /// + [Key(5)] public double ElapsedMilliseconds { get; set; } #endregion + /// + /// 时间戳 + /// + [Key(6)] + public long Timestamp { get; set;} + #region --- 快捷构造方法 --- /// diff --git a/SHH.Contracts/CommandPayload.cs b/SHH.Contracts/Commands/CommandPayload.cs similarity index 83% rename from SHH.Contracts/CommandPayload.cs rename to SHH.Contracts/Commands/CommandPayload.cs index 1013303..090948e 100644 --- a/SHH.Contracts/CommandPayload.cs +++ b/SHH.Contracts/Commands/CommandPayload.cs @@ -1,4 +1,5 @@ -using System; +using MessagePack; +using System; namespace SHH.Contracts { @@ -6,26 +7,41 @@ namespace SHH.Contracts /// 通用指令请求载体 (Request) /// 用于 NetMQ 的 Request-Reply 或 Router-Dealer 模式 /// + [MessagePackObject] public class CommandPayload { + #region --- 0. 协议自描述 --- + + /// + /// 协议类型标识 + /// 建议值: "COMMAND" 或 "指令包" + /// + [Key(0)] + public string Protocol { get; set; } = "COMMAND"; + + #endregion + #region --- 核心路由信息 --- /// /// 指令代码 (路由键) /// 示例: "PTZ", "RECORD_START", "SERVER_REGISTER" /// + [Key(1)] public string CmdCode { get; set; } /// /// 目标对象 ID /// 示例: 摄像头ID "101",或者系统级指令填 "SYSTEM" /// + [Key(2)] public string TargetId { get; set; } /// /// 业务参数 (JSON 字符串) /// 根据 CmdCode 的不同,反序列化为不同的 DTO (如 PtzControlDto) /// + [Key(3)] public string JsonParams { get; set; } #endregion @@ -36,11 +52,13 @@ namespace SHH.Contracts /// 请求追踪 ID (UUID) /// 核心字段:用于实现异步等待 (await)。回执包必须携带此 ID。 /// + [Key(4)] public string RequestId { get; set; } = Guid.NewGuid().ToString("N"); /// /// 发送时间戳 /// + [Key(5)] public DateTime Timestamp { get; set; } = DateTime.Now; #endregion @@ -52,6 +70,7 @@ namespace SHH.Contracts /// true: 发送端会 await 等待结果 (默认) /// false: 发后即忘 (Fire-and-Forget),服务端收到后不回发任何消息,减少带宽 /// + [Key(6)] public bool RequireAck { get; set; } = true; /// @@ -60,12 +79,14 @@ namespace SHH.Contracts /// 1, 2...: 第N次重试 /// 服务端据此判断是否需要查重 (幂等性处理) /// + [Key(7)] public int RetryCount { get; set; } = 0; /// /// 消息过期时间 (Unix时间戳) /// 如果接收端收到时已经超过此时间,直接丢弃,不处理也不回复 /// + [Key(8)] public long ExpireTime { get; set; } #endregion diff --git a/SHH.Contracts/Commands/DeviceStatusEvent.cs b/SHH.Contracts/Commands/DeviceStatusEvent.cs new file mode 100644 index 0000000..cbde5d3 --- /dev/null +++ b/SHH.Contracts/Commands/DeviceStatusEvent.cs @@ -0,0 +1,49 @@ +using MessagePack; +using System.Collections.Generic; + +namespace SHH.Contracts +{ + /// + /// [控制面] 状态全量快照包 + /// + [MessagePackObject] + public class StatusBatchPayload + { + // [新增] 协议类型标识 (人工可读) + // 建议值: "STATUS_BATCH" 或 "设备状态全量包" + [Key(0)] + public string Protocol { get; set; } = "STATUS_BATCH"; + + [Key(1)] + public List Items { get; set; } + = new List(); + + [Key(2)] + public long Timestamp { get; set; } + } + + /// + /// [控制面] 设备状态变更通知包 + /// + [MessagePackObject] + public class StatusEventPayload + { + [Key(0)] + public string CameraId { get; set; } + + /// + /// true: 上线/活跃, false: 离线/超时 + /// + [Key(1)] + public bool IsOnline { get; set; } + + /// + /// 变更原因 (e.g. "Ping Success", "Frame Timeout") + /// + [Key(2)] + public string Reason { get; set; } + + [Key(3)] + public long Timestamp { get; set; } + } +} \ No newline at end of file diff --git a/SHH.Contracts/Commands/ProtocolHeaders.cs b/SHH.Contracts/Commands/ProtocolHeaders.cs new file mode 100644 index 0000000..c7ea18b --- /dev/null +++ b/SHH.Contracts/Commands/ProtocolHeaders.cs @@ -0,0 +1,13 @@ +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 SyncCamera = "Sync_Camera"; + } +} \ No newline at end of file diff --git a/SHH.Contracts/ServerRegistrationDto.cs b/SHH.Contracts/Commands/RegisterPayload.cs similarity index 79% rename from SHH.Contracts/ServerRegistrationDto.cs rename to SHH.Contracts/Commands/RegisterPayload.cs index d21c96d..23ce45b 100644 --- a/SHH.Contracts/ServerRegistrationDto.cs +++ b/SHH.Contracts/Commands/RegisterPayload.cs @@ -1,4 +1,5 @@ -using System; +using MessagePack; +using System; namespace SHH.Contracts { @@ -6,24 +7,38 @@ namespace SHH.Contracts /// 服务端身份注册信息 (DTO) /// 用于服务端主动连上客户端后,上报自身的端口和身份信息 /// - public class ServerRegistrationDto + [MessagePackObject] + public class RegisterPayload { + #region --- 0. 协议自描述 --- + + /// + /// 协议类型标识 (人工可读) + /// + [Key(0)] + public string Protocol { get; set; } = ProtocolHeaders.ServerRegister; + + #endregion + #region --- 1. 身份标识 --- /// /// 进程 ID (用于区分同一台机器上的多个实例) /// + [Key(1)] public int ProcessId { get; set; } /// /// 实例唯一标识符 /// 启动时通过命令行传入,例如 "Gateway_Factory_A" /// + [Key(2)] public string InstanceId { get; set; } /// /// 服务端版本号 /// + [Key(3)] public string Version { get; set; } = "1.0.0"; #endregion @@ -34,22 +49,26 @@ namespace SHH.Contracts /// 服务端所在的局域网 IP /// 客户端无法直接连接此IP(因为可能是内网),但运维人员需要知道 /// + [Key(4)] public string ServerIp { get; set; } /// /// WebAPI 监听端口 (HTTP) /// 用于运维人员打开 Swagger 进行调试 /// + [Key(5)] public int WebApiPort { get; set; } /// /// 视频流端口 (ZeroMQ Publisher/Push) /// + [Key(6)] public int VideoPort { get; set; } /// /// 指令流端口 (ZeroMQ Response) /// + [Key(7)] public int CmdPort { get; set; } #endregion @@ -59,11 +78,13 @@ namespace SHH.Contracts /// /// 启动时间 /// + [Key(8)] public DateTime StartTime { get; set; } /// /// 描述信息 (可选) /// + [Key(9)] public string Description { get; set; } #endregion diff --git a/SHH.Contracts/VideoPayload.cs b/SHH.Contracts/Commands/VideoPayload.cs similarity index 85% rename from SHH.Contracts/VideoPayload.cs rename to SHH.Contracts/Commands/VideoPayload.cs index b8d1798..80de8cb 100644 --- a/SHH.Contracts/VideoPayload.cs +++ b/SHH.Contracts/Commands/VideoPayload.cs @@ -1,6 +1,6 @@ -using System; -using System.Collections.Generic; +using MessagePack; using Newtonsoft.Json; +using System.Collections.Generic; // 注意:如果不想依赖 Newtonsoft,也可以用 System.Text.Json,但 Newtonsoft 在 Std 2.0 中兼容性更好 namespace SHH.Contracts @@ -8,6 +8,7 @@ namespace SHH.Contracts /// /// 视频数据传输契约(纯净版 POCO) /// + [MessagePackObject] public class VideoPayload { public VideoPayload() @@ -18,35 +19,49 @@ namespace SHH.Contracts #region --- 1. 元数据 (Metadata) --- + [Key(0)] public string CameraId { get; set; } /// /// 采集时间戳 (Unix 毫秒) /// + [Key(1)] public long CaptureTimestamp { get; set; } /// /// 分发时间戳 (Unix 毫秒) /// + [Key(2)] public long DispatchTimestamp { get; set; } + [Key(3)] public int OriginalWidth { get; set; } + + [Key(4)] public int OriginalHeight { get; set; } + + [Key(5)] public int TargetWidth { get; set; } + + [Key(6)] public int TargetHeight { get; set; } - public List SubscriberIds { get; } + [Key(7)] + public List SubscriberIds { get; set; } - public Dictionary Diagnostics { get; } + [Key(8)] + public Dictionary Diagnostics { get; set; } /// /// 指示标志:是否存在原始图 /// + [Key(9)] public bool HasOriginalImage { get; set; } /// /// 指示标志:是否存在处理图 /// + [Key(10)] public bool HasTargetImage { get; set; } #endregion @@ -55,9 +70,11 @@ namespace SHH.Contracts // 标记 JsonIgnore,防止被错误序列化 [JsonIgnore] + [IgnoreMember] public byte[] OriginalImageBytes { get; set; } [JsonIgnore] + [IgnoreMember] public byte[] TargetImageBytes { get; set; } #endregion diff --git a/SHH.Contracts/SHH.Contracts.csproj b/SHH.Contracts/SHH.Contracts.csproj index 561ca6f..57dc38e 100644 --- a/SHH.Contracts/SHH.Contracts.csproj +++ b/SHH.Contracts/SHH.Contracts.csproj @@ -5,6 +5,7 @@ +