From 2754cdff152a073203f4f99280a971cbd48fa1e2 Mon Sep 17 00:00:00 2001 From: wilson Date: Thu, 15 Jan 2026 18:56:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=97=A5=E5=BF=97=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Ayay.SerilogLogs/Ayay.SerilogLogs.csproj | 21 ++ Ayay.SerilogLogs/LogBootstrapper.cs | 183 ++++++++++++ Ayay.SerilogLogs/LogCleaner.cs | 157 ++++++++++ Ayay.SerilogLogs/LogModules.cs | 28 ++ Ayay.SerilogLogs/LogOptions.cs | 154 ++++++++++ Ayay.Solution.sln | 10 +- SHH.CameraSdk/Configs/ServiceConfig.cs | 87 +++++- .../Drivers/HikVision/HikSdkManager.cs | 11 +- SHH.CameraSdk/SHH.CameraSdk.csproj | 2 + SHH.CameraService/Bootstrapper.cs | 269 ++++++++++++++++++ SHH.CameraService/Program.cs | 217 ++++++-------- SHH.CameraService/SHH.CameraService.csproj | 1 + .../Utils/ServiceCollectionExtensions.cs | 155 ++++++++++ 13 files changed, 1153 insertions(+), 142 deletions(-) create mode 100644 Ayay.SerilogLogs/Ayay.SerilogLogs.csproj create mode 100644 Ayay.SerilogLogs/LogBootstrapper.cs create mode 100644 Ayay.SerilogLogs/LogCleaner.cs create mode 100644 Ayay.SerilogLogs/LogModules.cs create mode 100644 Ayay.SerilogLogs/LogOptions.cs create mode 100644 SHH.CameraService/Bootstrapper.cs create mode 100644 SHH.CameraService/Utils/ServiceCollectionExtensions.cs diff --git a/Ayay.SerilogLogs/Ayay.SerilogLogs.csproj b/Ayay.SerilogLogs/Ayay.SerilogLogs.csproj new file mode 100644 index 0000000..0594d1f --- /dev/null +++ b/Ayay.SerilogLogs/Ayay.SerilogLogs.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + + + + + + + + + + + + + + + + + diff --git a/Ayay.SerilogLogs/LogBootstrapper.cs b/Ayay.SerilogLogs/LogBootstrapper.cs new file mode 100644 index 0000000..5e37ce1 --- /dev/null +++ b/Ayay.SerilogLogs/LogBootstrapper.cs @@ -0,0 +1,183 @@ +using Serilog; +using Serilog.Events; +using Serilog.Exceptions; // Nuget: Serilog.Exceptions +using Serilog.Enrichers.Span; // Nuget: Serilog.Enrichers.Span +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Ayay.SerilogLogs +{ + /// + /// 日志引导程序 + /// 负责初始化 Serilog 全局配置,包括文件策略、控制台输出、Seq 连接以及上下文丰富化。 + /// 实现了按日期分文件夹、按模块分文件、以及主次日志分离的复杂策略。 + /// + public static class LogBootstrapper + { + /// + /// 初始化日志系统 (通常在程序启动最开始调用) + /// + /// 配置选项 + public static void Init(LogOptions opts) + { + // -------------------------------------------------------- + // 1. 目录容错处理 (防止因为 D 盘不存在导致程序崩溃) + // -------------------------------------------------------- + string finalLogPath = opts.LogRootPath; + try + { + if (!Directory.Exists(finalLogPath)) + { + Directory.CreateDirectory(finalLogPath); + } + } + catch (Exception) + { + // 如果创建失败(比如没有 D 盘权限),回退到程序运行目录下的 Logs 文件夹 + finalLogPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); + } + + // -------------------------------------------------------- + // 2. 构建 Serilog 配置 + // -------------------------------------------------------- + var builder = new LoggerConfiguration(); + + // 2.1 设置全局最低门槛 (兜底策略) + builder.MinimumLevel.Is(opts.GlobalMinimumLevel); + + // 2.2 应用模块级别的特殊配置 (关键:处理 Algorithm=Debug, Ping=Fatal 等) + if (opts.ModuleLevels != null) + { + foreach (var module in opts.ModuleLevels) + { + builder.MinimumLevel.Override(module.Key, module.Value); + } + } + // 强制覆盖微软自带的啰嗦日志 + builder.MinimumLevel.Override("Microsoft", LogEventLevel.Warning); + builder.MinimumLevel.Override("System", LogEventLevel.Warning); + + // 2.3 注入全套元数据 (Enrichers) - 让日志更聪明 + builder + .Enrich.FromLogContext() // 允许使用 .ForContext() 注入上下文 + .Enrich.WithProperty("AppId", opts.AppId) // 注入应用标识 + .Enrich.WithThreadId() // 线程ID + .Enrich.WithProcessId() // 进程ID (用于识别重启) + .Enrich.WithExceptionDetails() // 结构化异常堆栈 + .Enrich.WithSpan(); // 全链路追踪 ID + + // -------------------------------------------------------- + // 3. 配置输出端 (Sinks) + // -------------------------------------------------------- + + // 定义通用模板:包含了 SourceContext (模块名), TraceId, AppId + // 示例: 2026-01-15 12:00:01 [INF] [Algorithm] [Dev01] 计算完成 + string outputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] [{AppId}] {Message:lj}{NewLine}{Exception}"; + + // 3.1 控制台输出 (开发调试用) + builder.WriteTo.Async(a => a.Console( + restrictedToMinimumLevel: opts.ConsoleLevel, + outputTemplate: outputTemplate + )); + + // ================================================================= + // 3.2 [核心逻辑] 双重 Map 实现: 日期文件夹 -> 模块文件 + // ================================================================= + + // 第一层 Map:根据【日期】分发 (key = "2026-01-15") + // 目的:实现 D:\Logs\App\2026-01-15\ 这样的目录结构 + builder.WriteTo.Map(le => le.Timestamp.ToString("yyyy-MM-dd"), (dateKey, dailyConfig) => + { + // 动态计算当天的文件夹路径 + var dailyPath = Path.Combine(finalLogPath, dateKey); + + // 第二层 Map:根据【模块 SourceContext】分发 + // 目的:在日期文件夹下,区分 System.txt, Network.txt + dailyConfig.Map("SourceContext", (moduleKey, moduleConfig) => + { + // 如果没填 SourceContext,默认归为 General + var moduleName = string.IsNullOrEmpty(moduleKey) ? "General" : moduleKey; + + // --- A. 配置【主要数据】文件 (Main) --- + // 规则: 只存 Information 及以上 + // 路径: .../2026-01-15/System-20260115.txt + moduleConfig.File( + path: Path.Combine(dailyPath, $"{moduleName}-.txt"), // RollingInterval.Day 会自动把 - 替换为 -yyyyMMdd + restrictedToMinimumLevel: LogEventLevel.Information, // 👈 过滤掉 Debug + rollingInterval: RollingInterval.Day, + + // 文件大小限制 (超过 10MB 切割 Main_001.txt) + fileSizeLimitBytes: opts.FileSizeLimitBytes, + rollOnFileSizeLimit: opts.RollOnFileSizeLimit, + + // ⚠️ 设为 null,因为我们有自定义的 LogCleaner 接管清理工作,避免 Serilog 内部逻辑冲突 + retainedFileCountLimit: null, + + encoding: Encoding.UTF8, + outputTemplate: outputTemplate, + shared: true // 允许跨进程/多线程共享 + ); + + // --- B. 配置【细节数据】文件 (Detail) --- + // 规则: 存 Debug 及以上 (包含 Main 的数据,是最全的备份) + // 路径: .../2026-01-15/SystemDetail-20260115.txt + moduleConfig.File( + path: Path.Combine(dailyPath, $"{moduleName}Detail-.txt"), + restrictedToMinimumLevel: LogEventLevel.Debug, // 👈 包含 Debug + rollingInterval: RollingInterval.Day, + + // 文件大小限制 (与 Main 保持一致) + fileSizeLimitBytes: opts.FileSizeLimitBytes, + rollOnFileSizeLimit: opts.RollOnFileSizeLimit, + + retainedFileCountLimit: null, + + encoding: Encoding.UTF8, + outputTemplate: outputTemplate, + shared: true + ); + + }, sinkMapCountLimit: 20); // 限制模块数量,防止 Context 乱填导致句柄爆炸 + + }, sinkMapCountLimit: 2); // 限制日期 Sink 数量 (只需要保持今天和昨天,防止跨天运行时内存不释放) + + // 3.3 Seq 远程输出 (生产监控) + if (!string.IsNullOrWhiteSpace(opts.SeqServerUrl)) + { + builder.WriteTo.Async(a => a.Seq( + serverUrl: opts.SeqServerUrl, + apiKey: opts.SeqApiKey, + restrictedToMinimumLevel: opts.SeqLevel + )); + } + + // -------------------------------------------------------- + // 4. 生成 Logger 并赋值给全局静态变量 + // -------------------------------------------------------- + Log.Logger = builder.CreateLogger(); + + // -------------------------------------------------------- + // 5. 启动后台清理任务 (LogCleaner) + // -------------------------------------------------------- + // 延迟 5 秒执行,避免在程序刚启动的高负载时刻争抢 IO 资源 + Task.Delay(5000).ContinueWith(_ => + { + // 调用我们在 LogCleaner.cs 中定义的静态方法 + LogCleaner.RunAsync( + opts + ); + }); + } + + /// + /// 关闭日志系统,确保缓冲区数据写入磁盘/网络 + /// 请在程序退出 (OnExit) 时调用 + /// + public static void Close() + { + Log.CloseAndFlush(); + } + } +} \ No newline at end of file diff --git a/Ayay.SerilogLogs/LogCleaner.cs b/Ayay.SerilogLogs/LogCleaner.cs new file mode 100644 index 0000000..82e4ea0 --- /dev/null +++ b/Ayay.SerilogLogs/LogCleaner.cs @@ -0,0 +1,157 @@ +using Serilog; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Ayay.SerilogLogs +{ + /// + /// 日志清理工具 + /// 弥补 Serilog 原生清理功能的不足,支持“按天数”和“按总大小”进行全局清理。 + /// ✅ 已适配多级目录结构,会自动清理删除文件后留下的空文件夹。 + /// + public static class LogCleaner + { + /// + /// 异步执行清理任务 + /// + /// 配置选项 + public static void RunAsync(LogOptions opts) + { + string rootPath = opts.LogRootPath; // 日志根目录 + int maxDays = opts.MaxRetentionDays; // 最大保留天数 + long maxBytes = opts.MaxTotalLogSize; // 最大总字节数 + + Task.Run(() => + { + try + { + CleanUp(rootPath, maxDays, maxBytes); + } + catch (Exception ex) + { + Log.Error(ex, "[LogCleaner] 日志自动清理任务执行失败"); + } + }); + } + + private static void CleanUp(string rootPath, int maxDays, long maxBytes) + { + var dirInfo = new DirectoryInfo(rootPath); + if (!dirInfo.Exists) return; + + // ========================================================= + // 第一步:清理文件 (递归查找所有子目录) + // ========================================================= + + // 获取所有 .txt 文件,按最后修改时间升序排列 (最旧的在前面) + // SearchOption.AllDirectories 确保了能扫描到 2026-01-15 这种子文件夹里的内容 + var allFiles = dirInfo.GetFiles("*.txt", SearchOption.AllDirectories) + .OrderBy(f => f.LastWriteTime) + .ToList(); + + if (allFiles.Count == 0) return; + + long currentTotalSize = 0; + var cutOffDate = DateTime.Now.Date.AddDays(-maxDays); // 只保留到今天之前的 N 天 + int deletedCount = 0; + + // --- 策略 A: 按时间清理 --- + for (int i = allFiles.Count - 1; i >= 0; i--) + { + var file = allFiles[i]; + + if (file.LastWriteTime.Date < cutOffDate) + { + try + { + file.Delete(); + allFiles.RemoveAt(i); + deletedCount++; + } + catch { /* 忽略占用 */ } + } + else + { + currentTotalSize += file.Length; + } + } + + if (deletedCount > 0) + { + Log.ForContext("SourceContext", LogModules.Core) + .Information("[LogCleaner] 时间策略: 已删除 {Count} 个过期文件", deletedCount); + } + + // --- 策略 B: 按总大小清理 --- + if (currentTotalSize > maxBytes) + { + int sizeDeletedCount = 0; + long freedBytes = 0; + + foreach (var file in allFiles) + { + if (currentTotalSize <= maxBytes) break; + + try + { + long len = file.Length; + file.Delete(); + currentTotalSize -= len; + freedBytes += len; + sizeDeletedCount++; + } + catch { /* 忽略占用 */ } + } + + if (sizeDeletedCount > 0) + { + Log.ForContext("SourceContext", LogModules.Core) + .Warning("[LogCleaner] 空间策略: 已删除 {Count} 个旧文件, 释放 {SizeMB:F2} MB", + sizeDeletedCount, freedBytes / 1024.0 / 1024.0); + } + } + + // ========================================================= + // 第二步:清理空文件夹 (新增逻辑) + // ========================================================= + // 目的:当 2026-01-01 文件夹里的文件都被删光后,这个文件夹本身也应该被删掉 + DeleteEmptyDirectories(dirInfo); + } + + /// + /// 递归删除空文件夹 + /// + private static void DeleteEmptyDirectories(DirectoryInfo dir) + { + // 1. 先递归处理子文件夹 + foreach (var subDir in dir.GetDirectories()) + { + DeleteEmptyDirectories(subDir); + } + + // 2. 检查当前文件夹是否为空 (且不是根目录自己) + try + { + // 重新刷新状态 + dir.Refresh(); + + // 如果没有文件 且 没有子文件夹 + if (dir.GetFiles().Length == 0 && dir.GetDirectories().Length == 0) + { + // 注意:不要删除根目录 LogRootPath + // 这里虽然逻辑上递归会删到底,但通常外层调用时传入的是 Logs 根目录, + // 只要 LogBootstrapper 里保证 Logs 存在,这里删掉子目录没问题。 + // 为了安全,可以判断一下是否是根目录的直接子级,或者简单地 try catch 忽略根目录无法删除的异常。 + + dir.Delete(); + } + } + catch + { + // 忽略异常 (比如文件夹正被打开,或者试图删除根目录但被占用) + } + } + } +} \ No newline at end of file diff --git a/Ayay.SerilogLogs/LogModules.cs b/Ayay.SerilogLogs/LogModules.cs new file mode 100644 index 0000000..8d1d3f1 --- /dev/null +++ b/Ayay.SerilogLogs/LogModules.cs @@ -0,0 +1,28 @@ +namespace Ayay.SerilogLogs +{ + /// + /// 统一管理系统中的日志模块名称(SourceContext) + /// 使用常量可以避免硬编码字符串带来的拼写错误 + /// + public static class LogModules + { + // --- 核心架构层 --- + public const string Core = "Core"; // 系统主逻辑/启动关闭 + public const string Network = "Network"; // 底层网络通讯 (TCP/UDP) + public const string WebApi = "WebAPI"; // 对外 HTTP 接口 + public const string WebSocket = "WebSocket"; // 实时通讯 + public const string Ping = "Ping"; // 心跳/Ping包 (通常量大且不重要) + + // --- 业务逻辑层 --- + public const string UserSystem = "UserSystem"; // 用户账户/权限系统 + public const string UserAction = "UserAction"; // 用户操作行为 (审计日志) + public const string DeviceOps = "DeviceOps"; // 设备运行/控制指令 + + // --- 核心算法层 --- + public const string Algorithm = "Algorithm"; // 算法分析/AI识别 (需要上下文追踪) + public const string Observation = "Observation"; // 观察点/埋点 (用于调试或统计) + + // --- 第三方集成 --- + public const string Sdk = "SDK"; // 第三方 SDK 调用封装 + } +} \ No newline at end of file diff --git a/Ayay.SerilogLogs/LogOptions.cs b/Ayay.SerilogLogs/LogOptions.cs new file mode 100644 index 0000000..1603575 --- /dev/null +++ b/Ayay.SerilogLogs/LogOptions.cs @@ -0,0 +1,154 @@ +using Serilog.Events; +using System.Collections.Generic; + +namespace Ayay.SerilogLogs +{ + /// + /// 日志组件配置选项 + /// 包含身份标识、存储路径、分级策略以及自动清理策略。 + /// + public class LogOptions + { + // ========================================== + // 1. 基础身份标识 + // ========================================== + + /// + /// 应用名称/服务ID。 + /// 例如: "VideoServer-01", "Gatekeeper-Api"。 + /// 在 Seq 中对应 {AppId} 属性,用于区分多服务环境下的日志来源。 + /// + public string AppId { get; set; } = "DefaultApp"; + + // ========================================== + // 2. 存储路径配置 + // ========================================== + + /// + /// 本地日志文件的存储根目录。 + /// 默认: @"D:\Logs" + /// 程序会自动在此目录下按模块创建子文件夹(如 System, Network)。 + /// 注意:如果该目录无写入权限,组件会自动降级到程序运行目录。 + /// + public string LogRootPath { get; set; } = @"D:\Logs"; + + // ========================================== + // 3. Seq 集中式日志配置 + // ========================================== + + /// + /// Seq 服务器地址。 + /// 例如: "http://192.168.1.100:5341" + /// 如果留空 (null/empty),则不启用 Seq 投递。 + /// + public string SeqServerUrl { get; set; } + + /// + /// Seq API Key (令牌)。 + /// 建议在 Seq 后台申请仅具有 [Ingest] 权限的 Key。 + /// 配置 Key 后支持动态调整日志级别 (Dynamic Level Control)。 + /// + public string SeqApiKey { get; set; } + + // ========================================== + // 4. 输出端级别控制 (Sink Levels) + // 用于控制不同媒介的“过滤网”疏密程度 + // ========================================== + + /// + /// 控制台输出的最低级别。 + /// 默认: Information (开发调试时可改为 Debug) + /// + public LogEventLevel ConsoleLevel { get; set; } = LogEventLevel.Information; + + /// + /// 本地文件记录的最低级别。 + /// 默认: Debug (保留详细案底,便于事后追溯) + /// 注意:具体写入哪个文件(Main/Detail)由内部逻辑决定,此属性控制总开关。 + /// + public LogEventLevel FileLevel { get; set; } = LogEventLevel.Verbose; + + /// + /// Seq 网络传输的最低级别。 + /// 默认: Information (减少网络带宽和服务器存储压力) + /// 生产环境建议设为 Information 或 Warning,除非需要在线排错。 + /// + public LogEventLevel SeqLevel { get; set; } = LogEventLevel.Verbose; + + // ========================================== + // 5. 业务模块级别控制 (Context Levels) + // 用于精细化控制特定业务模块的日志开关 + // ========================================== + + /// + /// 全局默认最低级别。 + /// 如果某个日志没有指定模块,或者模块不在 ModuleLevels 列表中,则使用此级别。 + /// + public LogEventLevel GlobalMinimumLevel { get; set; } = LogEventLevel.Verbose; + + /// + /// 针对特定业务模块的日志级别覆盖 (Override)。 + /// Key: 模块名称 (建议使用 LogModules 常量字符串) + /// Value: 该模块允许记录的最低级别 + /// + public Dictionary ModuleLevels { get; set; } = new Dictionary + { + // --- 系统层 --- + { LogModules.Core, LogEventLevel.Debug }, // 系统主逻辑 + { LogModules.Network, LogEventLevel.Debug }, // 网络通讯:平时只看警告,防止心跳刷屏 + { LogModules.WebApi, LogEventLevel.Debug }, // WebAPI:记录请求响应 + + // --- 业务层 --- + { LogModules.UserSystem, LogEventLevel.Debug }, // 用户系统 + { LogModules.UserAction, LogEventLevel.Debug }, // 用户操作:必须记录,用于审计 + { LogModules.DeviceOps, LogEventLevel.Debug }, // 设备操作:记录关键指令 + + // --- 核心/高频数据 --- + { LogModules.Algorithm, LogEventLevel.Debug }, // 算法:核心业务,开启 Debug 以记录全过程 + { LogModules.Observation, LogEventLevel.Debug }, // 观察点:最详细的埋点 + + // --- 降噪区 (垃圾数据屏蔽) --- + { LogModules.WebSocket, LogEventLevel.Debug }, // WS:数据量极大,除非报错否则不记 + { LogModules.Ping, LogEventLevel.Debug }, // Ping:几乎不记,除非完全断连 + { LogModules.Sdk, LogEventLevel.Debug } // SDK:屏蔽第三方的废话日志 + }; + + // ========================================== + // 6. 文件滚动策略 (Rolling Policy) + // 控制单个日志文件的大小和生成频率 + // ========================================== + + /// + /// 单个日志文件大小限制 (单位:字节)。 + /// 默认: 10MB (10 * 1024 * 1024) + /// 当文件超过此大小时,会自动创建新文件 (例如 Main_001.txt)。 + /// 建议不要设置过大,否则记事本打开会很卡。 + /// + public long FileSizeLimitBytes { get; set; } = 10 * 1024 * 1024; + + /// + /// 超过大小限制后是否创建新文件。 + /// 默认: true (推荐) + /// + public bool RollOnFileSizeLimit { get; set; } = true; + + // ========================================== + // 7. 自动清理策略 (Auto Cleanup) + // 由后台 LogCleaner 任务执行,满足任意条件即清理 + // ========================================== + + /// + /// 历史日志最大保留天数 (时间策略)。 + /// 默认: 30天 + /// 系统会强制删除最后修改时间超过此天数的文件。 + /// + public int MaxRetentionDays { get; set; } = 30; + + /// + /// 日志目录总大小上限 (空间策略)。 + /// 默认: 1GB (1024 * 1024 * 1024) + /// 如果所有日志文件总和超过此大小,系统会按时间倒序(从旧到新)删除文件,直到空间低于此值。 + /// + public long MaxTotalLogSize { get; set; } = 1024L * 1024 * 1024; + } +} \ No newline at end of file diff --git a/Ayay.Solution.sln b/Ayay.Solution.sln index d2bab2a..e783899 100644 --- a/Ayay.Solution.sln +++ b/Ayay.Solution.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.1.11312.151 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36623.8 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraSdk", "SHH.CameraSdk\SHH.CameraSdk.csproj", "{21B70A94-43FC-4D17-AB83-9E4B5178397E}" EndProject @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.ProcessLaunchers", "SHH EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.Contracts.Grpc", "SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj", "{5CBDD688-1CD0-4E63-81C5-8E18750D891A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ayay.SerilogLogs", "Ayay.SerilogLogs\Ayay.SerilogLogs.csproj", "{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +47,10 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SHH.CameraSdk/Configs/ServiceConfig.cs b/SHH.CameraSdk/Configs/ServiceConfig.cs index 5e20476..f7de9ca 100644 --- a/SHH.CameraSdk/Configs/ServiceConfig.cs +++ b/SHH.CameraSdk/Configs/ServiceConfig.cs @@ -6,13 +6,32 @@ public class ServiceConfig // 1. 基础属性 // ========================================== public int ParentPid { get; private set; } + public string AppId { get; private set; } = "Unknown_01"; + public int NumericId { get; private set; } = 1; + public int BasePort { get; private set; } = 5000; + public int MaxPortRange { get; private set; } = 100; + public NetworkMode Mode { get; private set; } = NetworkMode.Passive; + public bool ShouldConnect => Mode == NetworkMode.Active || Mode == NetworkMode.Hybrid; + public string SeqServerUrl { get; private set; } = string.Empty; + + public string SeqApiKey { get; private set; } = string.Empty; + + /// + /// 更新实际端口 + /// + /// + public void UpdateActualPort(int port) + { + this.BasePort = port; + } + // ========================================== // 2. 目标地址列表 (类型变了!) // ========================================== @@ -31,12 +50,16 @@ public class ServiceConfig for (int i = 0; i < args.Length; i++) { var key = args[i].ToLower().Trim(); + + // 确保不越界且下一个参数不是 key (防止 value 为空的情况) var value = (i + 1 < args.Length && !args[i + 1].StartsWith("--")) ? args[i + 1] : string.Empty; bool consumed = !string.IsNullOrEmpty(value); switch (key) { - case "--pid": if (int.TryParse(value, out int pid)) config.ParentPid = pid; break; + case "--pid": + if (int.TryParse(value, out int pid)) config.ParentPid = pid; + break; case "--appid": if (!string.IsNullOrWhiteSpace(value)) { @@ -49,12 +72,14 @@ public class ServiceConfig break; case "--mode": if (int.TryParse(value, out int m)) config.Mode = (NetworkMode)m; break; case "--ports": - if (!string.IsNullOrWhiteSpace(value) && value.Contains(",")) - { - var parts = value.Split(','); - if (parts.Length >= 1 && int.TryParse(parts[0], out int p)) config.BasePort = p; - if (parts.Length >= 2 && int.TryParse(parts[1], out int r)) config.MaxPortRange = r; - } + ParsePortConfig(config, value); + break; + case "--sequris": + config.SeqServerUrl = ParseSeqUri(value); + break; + case "--seqkey": + // 去掉可能存在的分号 + config.SeqApiKey = value.Replace(";", "").Trim(); break; } if (consumed) i++; @@ -62,6 +87,54 @@ public class ServiceConfig return config; } + /// + /// 解析端口配置 + /// + /// + /// + private static void ParsePortConfig(ServiceConfig config, string value) + { + if (!string.IsNullOrWhiteSpace(value) && value.Contains(",")) + { + var parts = value.Split(','); + if (parts.Length >= 1 && int.TryParse(parts[0], out int p)) config.BasePort = p; + if (parts.Length >= 2 && int.TryParse(parts[1], out int r)) config.MaxPortRange = r; + } + } + + /// + /// 解析 Seq URI + /// + /// + /// + private static string ParseSeqUri(string rawValue) + { + // 格式: 172.16.41.241,20026,日志处置中心; + try + { + rawValue = rawValue.Replace("\"", "").TrimEnd(';'); // 清理引号和末尾分号 + var parts = rawValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length >= 2) + { + string ip = parts[0].Trim(); + string port = parts[1].Trim(); + // 组装成标准 HTTP 格式 + return $"http://{ip}:{port}"; + } + return string.Empty; + } + catch + { + return string.Empty; + } + } + + /// + /// 从 AppId 中解析 ID + /// + /// + /// private static int ParseIdFromAppId(string appId) { if (string.IsNullOrWhiteSpace(appId)) return 1; diff --git a/SHH.CameraSdk/Drivers/HikVision/HikSdkManager.cs b/SHH.CameraSdk/Drivers/HikVision/HikSdkManager.cs index 84a43ab..f093812 100644 --- a/SHH.CameraSdk/Drivers/HikVision/HikSdkManager.cs +++ b/SHH.CameraSdk/Drivers/HikVision/HikSdkManager.cs @@ -1,4 +1,7 @@ -namespace SHH.CameraSdk; +using Ayay.SerilogLogs; +using Serilog; + +namespace SHH.CameraSdk; /// /// [驱动支持层] 海康 SDK 全局资源管理器 (V3.3.1 修复版) @@ -103,7 +106,8 @@ public static class HikSdkManager // 已预热过则直接返回,避免重复执行 if (_isWarmedUp) return; - Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] 正在进行播放库硬件探测预热,请稍候..."); + Log.ForContext("SourceContext", LogModules.Core) + .Debug($"正在进行播放库硬件探测预热,请稍候..."); Stopwatch sw = Stopwatch.StartNew(); int tempPort = -1; @@ -118,7 +122,8 @@ public static class HikSdkManager sw.Stop(); _isWarmedUp = true; - Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] 预热完成!耗时: {sw.ElapsedMilliseconds}ms。后续调用将恢复正常。"); + Log.ForContext("SourceContext", LogModules.Core) + .Debug($"预热完成!耗时: {sw.ElapsedMilliseconds}ms."); } #endregion diff --git a/SHH.CameraSdk/SHH.CameraSdk.csproj b/SHH.CameraSdk/SHH.CameraSdk.csproj index 698573b..ba3f960 100644 --- a/SHH.CameraSdk/SHH.CameraSdk.csproj +++ b/SHH.CameraSdk/SHH.CameraSdk.csproj @@ -15,6 +15,7 @@ + @@ -22,6 +23,7 @@ + diff --git a/SHH.CameraService/Bootstrapper.cs b/SHH.CameraService/Bootstrapper.cs new file mode 100644 index 0000000..5cededc --- /dev/null +++ b/SHH.CameraService/Bootstrapper.cs @@ -0,0 +1,269 @@ +using System.Net.Sockets; +using System.Net; +using Ayay.SerilogLogs; +using Grpc.Net.Client; +using Serilog; +using SHH.CameraSdk; +using SHH.Contracts.Grpc; + +namespace SHH.CameraService; + +public static class Bootstrapper +{ + #region ParseConfigAndInitLogger + + /// + /// 解析参数并初始化 Serilog + /// + public static (ServiceConfig Config, LogOptions opts, bool IsDebug) ParseConfigAndInitLogger(string[] args) + { + bool isDebugArgs = args.Length == 0; + + // ================================================================================= + // 1. 模拟调试参数 + // 原理:直接构造 string[],完全模拟 OS 传递给 Main 的参数结构,避免 Split 带来的空格解析风险 + // ================================================================================= + if (isDebugArgs) + { + args = new[] + { + "--appid", "CameraApp_01", + + // 视频流地址 (格式: IP,Port,Type,Desc) + "--uris", "localhost,9001,video,调试PC;", + "--uris", "localhost,9002,video,调试PC-2;", + + // 指令通道 + "--uris", "localhost,9001,command,调试PC;", + + // 日志中心配置 (格式: IP,Port,Desc) + "--sequris", "172.16.41.241,20026,日志处置中心;", + "--seqkey", "Shine899195994250;", + + // 端口策略 + "--mode", "1", + "--ports", "5000,100" + }; + } + + var config = ServiceConfig.BuildFromArgs(args); + + var ops = new LogOptions + { + AppId = config.AppId, + LogRootPath = $@"D:\Logs\{config.AppId}", + + // ★这里改为从 config 读取,如果没配则留空或给个默认值 + SeqServerUrl = config.SeqServerUrl, + SeqApiKey = config.SeqApiKey, + + MaxRetentionDays = 10, + FileSizeLimitBytes = 1024L * 1024 * 1024, + + // 动态设置日志级别 + ModuleLevels = new Dictionary + { + // 确保 Core 模块在调试时能输出 Debug,在生产时输出 Info + { LogModules.Core, isDebugArgs ? Serilog.Events.LogEventLevel.Debug : Serilog.Events.LogEventLevel.Information }, + { LogModules.Network, Serilog.Events.LogEventLevel.Warning } + } + }; + LogBootstrapper.Init(ops); + + return (config, ops, isDebugArgs); + } + + #endregion + + #region Shutdown + + /// + /// 统一的资源释放与关闭方法 + /// + /// + /// 统一的资源释放与关闭方法 + /// + /// 退出原因 + /// 退出码 (0=正常, 非0=异常) + public static void Shutdown(string reason, int exitCode = 0) + { + // 创建一个临时的上下文 Logger,防止全局 Logger 已被部分释放 + var sysLog = Log.ForContext("SourceContext", LogModules.Core); + + try + { + // ========================================================= + // 1. 写日志 + // ========================================================= + if (exitCode != 0) + { + sysLog.Fatal($"💀 [程序终止] {reason} (Code: {exitCode})"); + } + else + { + sysLog.Information($"👋 [程序退出] {reason}"); + } + + // ========================================================= + // 2. 核心硬件资源释放 (关键!) + // ========================================================= + // 防止 SDK 句柄残留导致下次启动无法连接相机 + try + { + sysLog.Information("正在清理 Hikvision SDK 资源..."); + + // 如果你的项目中引用了 SDK,请务必解开这行注释 + HikNativeMethods.NET_DVR_Cleanup(); + } + catch (Exception ex) + { + sysLog.Error(ex, "⚠️ SDK 资源清理失败"); + } + + // ========================================================= + // 3. 日志强制落盘 + // ========================================================= + // Environment.Exit 是暴力退出,finally 块不会执行, + // 必须手动 Flush 确保最后一条日志写入磁盘。 + Log.CloseAndFlush(); + } + catch + { + // 忽略所有清理过程中的错误,确保一定要执行到 Environment.Exit + } + + // ========================================================= + // 4. 开发环境交互 (生产环境自动跳过) + // ========================================================= + // 只有在调试器挂载时才暂停,防止 Docker/Service 环境卡死 + if (System.Diagnostics.Debugger.IsAttached) + { + Console.ForegroundColor = exitCode == 0 ? ConsoleColor.Green : ConsoleColor.Red; + Console.WriteLine($"\n[Debug模式] 按下任意键退出... (ExitCode: {exitCode})"); + Console.ResetColor(); + Console.ReadKey(); + } + + // ========================================================= + // 5. 暴力且彻底地结束进程 + // ========================================================= + Environment.Exit(exitCode); + } + + #endregion + + #region ScanForAvailablePort + + /// + /// 扫描可用端口 + /// + public static int ScanForAvailablePort(ServiceConfig config, ILogger logger) + { + logger.Information($"🔍 开始端口检测: 起始={config.BasePort}, 范围={config.MaxPortRange}"); + + for (int i = 0; i <= config.MaxPortRange; i++) + { + int currentPort = config.BasePort + i; + if (CheckPortAvailable(currentPort)) + { + if (currentPort != config.BasePort) + { + logger.Warning($"⚙️ 端口自动漂移: {config.BasePort} -> {currentPort}"); + } + else + { + logger.Information($"✅ 端口检测通过: {currentPort}"); + } + return currentPort; + } + logger.Debug($"⚠️ 端口 {currentPort} 被占用,尝试下一个..."); + } + return -1; + } + + #endregion + + #region CheckPortAvailable + + /// + /// 检查端口是否可用 + /// + /// + /// + private static bool CheckPortAvailable(int port) + { + try + { + using var listener = new TcpListener(IPAddress.Any, port); + listener.Start(); + listener.Stop(); + return true; + } + catch + { + return false; + } + } + + #endregion + + #region WarmUpHardware + + /// + /// 硬件预热 + /// + public static void WarmUpHardware(ILogger logger) + { + logger.Information("Hik Sdk 开始预热."); + try + { + HikNativeMethods.NET_DVR_Init(); + HikSdkManager.ForceWarmUp(); + logger.Information("💡Hik Sdk 预热成功."); + } + catch (Exception ex) + { + logger.Error(ex, "⚠️ Hik Sdk 预热失败."); + } + } + + #endregion + + #region RegisterToGatewayAsync + + /// + /// 向网关注册实例 + /// + public static async Task RegisterToGatewayAsync(ServiceConfig config, ILogger logger) + { + if (!config.CommandEndpoints.Any()) return; + + try + { + // 将 tcp:// 转换为 http:// 以适配 gRPC + string targetUrl = config.CommandEndpoints.First().Uri.Replace("tcp://", "http://"); + + using var channel = GrpcChannel.ForAddress(targetUrl); + var client = new GatewayProvider.GatewayProviderClient(channel); + + logger.Information($"[Grpc] 正在执行预注册: {targetUrl}"); + var resp = await client.RegisterInstanceAsync(new RegisterRequest + { + InstanceId = config.AppId, + Version = "2.0.0-grpc", + ServerIp = "127.0.0.1", + WebapiPort = config.BasePort, // 使用扫描后的新端口 + StartTimeTicks = DateTime.Now.Ticks, + ProcessId = Environment.ProcessId, + Description = "" + }); + logger.Information($"💡[Grpc] 预注册成功: {resp.Message}"); + } + catch (Exception ex) + { + logger.Error($"⚠️ [Grpc] 预注册尝试失败: {ex.Message}"); + } + } + + #endregion +} \ No newline at end of file diff --git a/SHH.CameraService/Program.cs b/SHH.CameraService/Program.cs index 3817478..03b07ab 100644 --- a/SHH.CameraService/Program.cs +++ b/SHH.CameraService/Program.cs @@ -1,121 +1,63 @@ -using Grpc.Net.Client; +using Ayay.SerilogLogs; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using Microsoft.OpenApi.Models; +using Serilog; using SHH.CameraSdk; -using SHH.Contracts.Grpc; -using Microsoft.Extensions.Logging; namespace SHH.CameraService; public class Program { + /// + /// 主程序 + /// + /// + /// public static async Task Main(string[] args) { - // 2. 硬件预热 (静态方法保留) - HikNativeMethods.NET_DVR_Init(); - HikSdkManager.ForceWarmUp(); - // 1. [核心环境] 必须在所有网络操作前开启 AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); - // 2. 模拟/解析配置 - if (args.Length == 0) - { - string serviceArgs = "--appid CameraApp_01 " + - "--uris localhost,9001,video,调试PC; " + - "--uris localhost,9001,command,调试PC; " + - "--mode 1 --ports 5000,100"; - args = serviceArgs.Split(' ', StringSplitOptions.RemoveEmptyEntries); - } - var config = ServiceConfig.BuildFromArgs(args); + // 2. 解析配置与初始化日志 + var (config, opts, isDebugArgs) = Bootstrapper.ParseConfigAndInitLogger(args); + var sysLog = Log.ForContext("SourceContext", LogModules.Core); // ============================================================= - // 3. 【强行复刻成功逻辑】 在 Web 容器启动前直接执行注册 + // 1. 启动日志 // ============================================================= - if (config.CommandEndpoints.Any()) - { - try - { - // 将 tcp:// 转换为 http:// 以适配 gRPC - string targetUrl = config.CommandEndpoints.First().Uri.Replace("tcp://", "http://"); - - using var channel = GrpcChannel.ForAddress(targetUrl); - var client = new GatewayProvider.GatewayProviderClient(channel); - - Console.WriteLine($"[gRPC] 正在执行预注册 (环境: 纯净): {targetUrl}"); - var resp = await client.RegisterInstanceAsync(new RegisterRequest - { - InstanceId = config.AppId, - Version = "2.0.0-grpc", - ServerIp = "127.0.0.1", - WebapiPort = config.BasePort, - StartTimeTicks = DateTime.Now.Ticks, - ProcessId = Environment.ProcessId, - Description = "Camera Service" - }); - Console.WriteLine($"[gRPC] 预注册成功: {resp.Message}"); - } - catch (Exception ex) - { - Console.WriteLine($"[gRPC] 预注册尝试失败 (不影响启动): {ex.Message}"); - } - } + sysLog.Warning($"🚀 视频取流进程启动, 日志组件初始化完毕 => 进程: {opts.AppId}"); + + string argString = string.Join(" ", args); + sysLog.Debug($"🚀 启动参数({(isDebugArgs ? "调试环境" : "生产环境")}): {argString}"); // ============================================================= - // 4. 构建 Web 主机环境 + // 2. 硬件预热、端口扫描、gRPC链接 + // ============================================================= + Bootstrapper.WarmUpHardware(sysLog); + + // 端口自动扫描 (必须做,否则端口冲突) + int activePort = Bootstrapper.ScanForAvailablePort(config, sysLog); + if (activePort == -1) + { + sysLog.Fatal("💀 无法启动:配置范围内无可用端口"); + Bootstrapper.Shutdown("无法启动:配置范围内无可用端口", exitCode: 1); + return; + } + config.UpdateActualPort(activePort); // 回填端口 + + // 具体的 gRPC 链接逻辑封装在 Bootstrapper 中,保持 Main 清爽但逻辑可见 + await Bootstrapper.RegisterToGatewayAsync(config, sysLog); + + // ============================================================= + // 3. 构建 Web 主机环境 // ============================================================= var builder = WebApplication.CreateBuilder(args); - // 基础业务单例注册 - builder.Services.AddSingleton(config); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => new ImageScaleCluster(4, sp.GetRequiredService())); - builder.Services.AddSingleton(sp => new ImageEnhanceCluster(4, sp.GetRequiredService())); - builder.Services.AddHostedService(); + // ★ 核心改动:一行代码注册所有业务 (SDK, Workers, gRPC, 视频流) + builder.Services.AddCameraBusinessServices(config, sysLog); - // 接入 SDK 核心逻辑 - builder.Services.AddCameraSdk(config.NumericId); - builder.Services.AddHostedService(); - - // ★ 注册 gRPC 版本的状态监控工作者 (不讲道理,直接注册) - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - - // ============================================================= - // 5. 视频流 Target 注册 (gRPC 模式) - // ============================================================= - var netTargets = new List(); - if (config.VideoEndpoints != null) - { - foreach (var cfgVideo in config.VideoEndpoints) - { - netTargets.Add(new StreamTarget(new PushTargetConfig - { - Name = cfgVideo.Description, - Endpoint = cfgVideo.Uri, - QueueCapacity = 10, - })); - } - } - builder.Services.AddSingleton>(netTargets); - builder.Services.AddHostedService(); - - // 为每个 Target 绑定一个 gRPC 流发送者 - foreach (var target in netTargets) - { - builder.Services.AddHostedService(sp => - new GrpcSenderWorker(target, sp.GetRequiredService>())); - } - - // 注册指令分发 (不再使用 NetMQ 的 CommandClientWorker) - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - - ConfigureWebServices(builder, config); + // ★ 核心改动:注册 Web 基础 (Controller, Swagger, Cors) + builder.Services.AddWebSupport(config); // ============================================================= // 6. 启动服务 @@ -123,51 +65,66 @@ public class Program var app = builder.Build(); // 激活 SDK 管理器并启动业务点火 - await StartBusinessLogic(app); + await StartBusinessLogic(app, sysLog); - app.UseSwagger(); - app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", $"SHH Gateway #{config.AppId}")); + // ★ 核心改动:配置 HTTP 管道 (Swagger, MapControllers 等) + app.ConfigurePipeline(config); - app.MapGet("/", () => $"SHH Gateway {config.AppId} is running (gRPC Mode)."); - app.UseCors("AllowAll"); - app.MapControllers(); + // 启动监听 + string url = $"http://0.0.0.0:{config.BasePort}"; + sysLog.Information($"🚀 [WebApi] 服务启动,监听: {url}"); - Console.WriteLine($"[System] 正在启动 Web 服务,监听端口: {config.BasePort}"); - await app.RunAsync($"http://0.0.0.0:{config.BasePort}"); + await app.RunAsync(url); } /// /// 激活单例并启动相机管理器 /// - static async Task StartBusinessLogic(WebApplication app) + /// + /// + static async Task StartBusinessLogic(WebApplication app, Serilog.ILogger logger) { var manager = app.Services.GetRequiredService(); + + // 激活哨兵 _ = app.Services.GetRequiredService(); + await manager.StartAsync(); - Console.WriteLine("[System] 核心业务逻辑已激活。"); + Console.WriteLine("✅[System] 核心业务逻辑已激活。"); } +} - /// - /// 注册 Web API 支持 - /// - static void ConfigureWebServices(WebApplicationBuilder builder, ServiceConfig cfg) - { - builder.Services.AddCors(options => - { - options.AddPolicy("AllowAll", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); - }); - - builder.Services.AddControllers(options => - { - options.Filters.Add(); - }) - .AddApplicationPart(typeof(CamerasController).Assembly) - .AddApplicationPart(typeof(MonitorController).Assembly); - - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(c => - { - c.SwaggerDoc("v1", new OpenApiInfo { Title = $"SHH Gateway #{cfg.AppId}", Version = "v1" }); - }); - } -} \ No newline at end of file +/* +🚀 启动/发布 程序启动、服务预热、开始监听 +🏁 结束/终点 批量任务全部完成、程序正常退出 +🔄 重试/循环 正在重试连接、定时任务触发、同步数据中 +⏳ 等待/耗时 长耗时操作开始、排队中 +💤 休眠/闲置 线程挂起、服务进入待机模式 +🌐 网络/HTTP HTTP 请求、API 调用、Web 服务 +🔌 连接 数据库连接建立、Socket 连接 +📡 信号/广播 发送 MQ 消息、广播通知、取流 +💾 存储/磁盘 写入文件、数据库落盘、缓存读写 +🔒 安全/锁 加密、解密、登录成功、获取分布式锁 +⚙️ 配置/系统 加载配置、系统底层操作 +🐞 Bug/调试 捕捉到的异常、临时调试信息 +🧪 测试/实验 单元测试环境、灰度测试代码 +🔍 搜索/检查 查询数据库、检查文件是否存在 +💡 提示/发现 逻辑分支提示、参数值打印 +🔴 红:致命/严重 (Fatal/Error) +🟡 黄:警告 (Warning) +🟢 绿:正常/成功 (Info/Success) +🔵 蓝:数据/网络 (Data/Network) +⚪ 灰:细节/忽略 (Debug/Verbose) +✅ Check Mark Button \u{2705} ✅ +🆗 OK Button \u{1F197} 🆗 +🔚 END Arrow (逻辑结束) \u{1F51A} 🔚 +💯 Hundred Points (完美结束) \u{1F4AF} 💯 +🛑 Stop Sign (最强提示) \u{1F6D1} 🛑 +⛔ No Entry (禁止/中断) \u{26D4} ⛔ +🚫 Prohibited (非法操作终止) \u{1F6AB} 🚫 +⏹️ Stop Button (播放器风格) \u{23F9} ⏹ +❌ Cross Mark (任务失败结束) \u{274C} ❌ +💀 Skull (进程被 Kill) \u{1F480} 💀 +⚰️ Coffin (彻底销毁) \u{26B0} ⚰ +👻 Ghost (变成孤儿进程) \u{1F47B} 👻 + */ \ No newline at end of file diff --git a/SHH.CameraService/SHH.CameraService.csproj b/SHH.CameraService/SHH.CameraService.csproj index 22199f8..1b93452 100644 --- a/SHH.CameraService/SHH.CameraService.csproj +++ b/SHH.CameraService/SHH.CameraService.csproj @@ -22,6 +22,7 @@ + diff --git a/SHH.CameraService/Utils/ServiceCollectionExtensions.cs b/SHH.CameraService/Utils/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..2d19ba0 --- /dev/null +++ b/SHH.CameraService/Utils/ServiceCollectionExtensions.cs @@ -0,0 +1,155 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; // 用于泛型 ILogger<> +using Microsoft.OpenApi.Models; +using SHH.CameraSdk; + +// 确保 namespace 与 Program.cs 的引用一致 +namespace SHH.CameraService; + +public static class ServiceCollectionExtensions +{ + // ======================================================================== + // 1. 核心聚合方法 (对应 Program.cs 中的调用) + // ======================================================================== + + #region AddCameraBusinessServices + + /// + /// [聚合] 注册所有核心业务 (包含逻辑处理、SDK、Gateway、视频流) + /// + public static void AddCameraBusinessServices(this IServiceCollection services, ServiceConfig config, Serilog.ILogger logger) + { + // 1.1 注册基础业务逻辑 + services.AddBusinessServices(config); + + // 1.2 注册视频流相关 (因为需要 Logger,所以单独传) + services.AddStreamTargets(config, logger); + } + + #endregion + + #region AddBusinessServices + + /// + /// 集中注册 SDK、Workers、Gateway 等纯业务服务 + /// + private static void AddBusinessServices(this IServiceCollection services, ServiceConfig config) + { + // 基础单例 + services.AddSingleton(config); + services.AddSingleton(); + + // 图像处理集群 + services.AddSingleton(sp => new ImageScaleCluster(4, sp.GetRequiredService())); + services.AddSingleton(sp => new ImageEnhanceCluster(4, sp.GetRequiredService())); + services.AddHostedService(); + + // SDK 与核心引擎 + services.AddCameraSdk(config.NumericId); + services.AddHostedService(); + + // 监控与网关 + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + + // 指令分发系统 + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + #endregion + + #region AddStreamTargets + + /// + /// 注册视频流目标 (StreamTargets & GrpcSender) + /// + private static void AddStreamTargets(this IServiceCollection services, ServiceConfig config, Serilog.ILogger logger) + { + var netTargets = new List(); + if (config.VideoEndpoints != null) + { + foreach (var cfgVideo in config.VideoEndpoints) + { + netTargets.Add(new StreamTarget(new PushTargetConfig + { + Name = cfgVideo.Description, + Endpoint = cfgVideo.Uri, + QueueCapacity = 10, + })); + } + } + + logger.Information("📋 加载视频流目标: {Count} 个", netTargets.Count); + + services.AddSingleton>(netTargets); + services.AddHostedService(); + + // 动态注册 Sender Worker + foreach (var target in netTargets) + { + // 注意:这里需要使用 Microsoft.Extensions.Logging.ILogger 来适配构造函数 + services.AddHostedService(sp => + new GrpcSenderWorker(target, sp.GetRequiredService>())); + } + } + + #endregion + + // ======================================================================== + // 2. Web 支持与管道 (Program.cs 调用的另外两个方法) + // ======================================================================== + + #region AddWebSupport + + /// + /// 注册 Controller, Swagger, Cors + /// + public static void AddWebSupport(this IServiceCollection services, ServiceConfig config) + { + services.AddCors(options => + { + options.AddPolicy("AllowAll", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); + }); + + // 注册 Controller 并添加过滤器 + services.AddControllers(options => + { + options.Filters.Add(); + }) + .AddApplicationPart(typeof(CamerasController).Assembly) // 确保能扫描到 Controller + .AddApplicationPart(typeof(MonitorController).Assembly); + + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = $"SHH Gateway #{config.AppId}", Version = "v1" }); + }); + } + + #endregion + + #region ConfigurePipeline + + /// + /// 配置 HTTP 中间件管道 + /// + public static void ConfigurePipeline(this WebApplication app, ServiceConfig config) + { + // 开启 Swagger + app.UseSwagger(); + app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", $"SHH Gateway #{config.AppId}")); + + // 简单健康检查端点 + app.MapGet("/", () => $"SHH Gateway {config.AppId} is running."); + + app.UseCors("AllowAll"); + app.MapControllers(); + } + + #endregion +} \ No newline at end of file