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