增加日志组件

This commit is contained in:
2026-01-15 18:56:39 +08:00
parent 801ffb25fd
commit 2754cdff15
13 changed files with 1153 additions and 142 deletions

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Span" Version="3.1.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.Map" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -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
{
/// <summary>
/// 日志引导程序
/// <para>负责初始化 Serilog 全局配置包括文件策略、控制台输出、Seq 连接以及上下文丰富化。</para>
/// <para>实现了按日期分文件夹、按模块分文件、以及主次日志分离的复杂策略。</para>
/// </summary>
public static class LogBootstrapper
{
/// <summary>
/// 初始化日志系统 (通常在程序启动最开始调用)
/// </summary>
/// <param name="opts">配置选项</param>
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
);
});
}
/// <summary>
/// 关闭日志系统,确保缓冲区数据写入磁盘/网络
/// <para>请在程序退出 (OnExit) 时调用</para>
/// </summary>
public static void Close()
{
Log.CloseAndFlush();
}
}
}

View File

@@ -0,0 +1,157 @@
using Serilog;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace Ayay.SerilogLogs
{
/// <summary>
/// 日志清理工具
/// <para>弥补 Serilog 原生清理功能的不足,支持“按天数”和“按总大小”进行全局清理。</para>
/// <para>✅ 已适配多级目录结构,会自动清理删除文件后留下的空文件夹。</para>
/// </summary>
public static class LogCleaner
{
/// <summary>
/// 异步执行清理任务
/// </summary>
/// <param name="opts">配置选项</param>
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);
}
/// <summary>
/// 递归删除空文件夹
/// </summary>
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
{
// 忽略异常 (比如文件夹正被打开,或者试图删除根目录但被占用)
}
}
}
}

View File

@@ -0,0 +1,28 @@
namespace Ayay.SerilogLogs
{
/// <summary>
/// 统一管理系统中的日志模块名称SourceContext
/// <para>使用常量可以避免硬编码字符串带来的拼写错误</para>
/// </summary>
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 调用封装
}
}

View File

@@ -0,0 +1,154 @@
using Serilog.Events;
using System.Collections.Generic;
namespace Ayay.SerilogLogs
{
/// <summary>
/// 日志组件配置选项
/// <para>包含身份标识、存储路径、分级策略以及自动清理策略。</para>
/// </summary>
public class LogOptions
{
// ==========================================
// 1. 基础身份标识
// ==========================================
/// <summary>
/// 应用名称/服务ID。
/// <para>例如: "VideoServer-01", "Gatekeeper-Api"。</para>
/// <para>在 Seq 中对应 {AppId} 属性,用于区分多服务环境下的日志来源。</para>
/// </summary>
public string AppId { get; set; } = "DefaultApp";
// ==========================================
// 2. 存储路径配置
// ==========================================
/// <summary>
/// 本地日志文件的存储根目录。
/// <para>默认: @"D:\Logs"</para>
/// <para>程序会自动在此目录下按模块创建子文件夹(如 System, Network。</para>
/// <para>注意:如果该目录无写入权限,组件会自动降级到程序运行目录。</para>
/// </summary>
public string LogRootPath { get; set; } = @"D:\Logs";
// ==========================================
// 3. Seq 集中式日志配置
// ==========================================
/// <summary>
/// Seq 服务器地址。
/// <para>例如: "http://192.168.1.100:5341"</para>
/// <para>如果留空 (null/empty),则不启用 Seq 投递。</para>
/// </summary>
public string SeqServerUrl { get; set; }
/// <summary>
/// Seq API Key (令牌)。
/// <para>建议在 Seq 后台申请仅具有 [Ingest] 权限的 Key。</para>
/// <para>配置 Key 后支持动态调整日志级别 (Dynamic Level Control)。</para>
/// </summary>
public string SeqApiKey { get; set; }
// ==========================================
// 4. 输出端级别控制 (Sink Levels)
// 用于控制不同媒介的“过滤网”疏密程度
// ==========================================
/// <summary>
/// 控制台输出的最低级别。
/// <para>默认: Information (开发调试时可改为 Debug)</para>
/// </summary>
public LogEventLevel ConsoleLevel { get; set; } = LogEventLevel.Information;
/// <summary>
/// 本地文件记录的最低级别。
/// <para>默认: Debug (保留详细案底,便于事后追溯)</para>
/// <para>注意:具体写入哪个文件(Main/Detail)由内部逻辑决定,此属性控制总开关。</para>
/// </summary>
public LogEventLevel FileLevel { get; set; } = LogEventLevel.Verbose;
/// <summary>
/// Seq 网络传输的最低级别。
/// <para>默认: Information (减少网络带宽和服务器存储压力)</para>
/// <para>生产环境建议设为 Information 或 Warning除非需要在线排错。</para>
/// </summary>
public LogEventLevel SeqLevel { get; set; } = LogEventLevel.Verbose;
// ==========================================
// 5. 业务模块级别控制 (Context Levels)
// 用于精细化控制特定业务模块的日志开关
// ==========================================
/// <summary>
/// 全局默认最低级别。
/// <para>如果某个日志没有指定模块,或者模块不在 ModuleLevels 列表中,则使用此级别。</para>
/// </summary>
public LogEventLevel GlobalMinimumLevel { get; set; } = LogEventLevel.Verbose;
/// <summary>
/// 针对特定业务模块的日志级别覆盖 (Override)。
/// <para>Key: 模块名称 (建议使用 LogModules 常量字符串)</para>
/// <para>Value: 该模块允许记录的最低级别</para>
/// </summary>
public Dictionary<string, LogEventLevel> ModuleLevels { get; set; } = new Dictionary<string, LogEventLevel>
{
// --- 系统层 ---
{ 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)
// 控制单个日志文件的大小和生成频率
// ==========================================
/// <summary>
/// 单个日志文件大小限制 (单位:字节)。
/// <para>默认: 10MB (10 * 1024 * 1024)</para>
/// <para>当文件超过此大小时,会自动创建新文件 (例如 Main_001.txt)。</para>
/// <para>建议不要设置过大,否则记事本打开会很卡。</para>
/// </summary>
public long FileSizeLimitBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>
/// 超过大小限制后是否创建新文件。
/// <para>默认: true (推荐)</para>
/// </summary>
public bool RollOnFileSizeLimit { get; set; } = true;
// ==========================================
// 7. 自动清理策略 (Auto Cleanup)
// 由后台 LogCleaner 任务执行,满足任意条件即清理
// ==========================================
/// <summary>
/// 历史日志最大保留天数 (时间策略)。
/// <para>默认: 30天</para>
/// <para>系统会强制删除最后修改时间超过此天数的文件。</para>
/// </summary>
public int MaxRetentionDays { get; set; } = 30;
/// <summary>
/// 日志目录总大小上限 (空间策略)。
/// <para>默认: 1GB (1024 * 1024 * 1024)</para>
/// <para>如果所有日志文件总和超过此大小,系统会按时间倒序(从旧到新)删除文件,直到空间低于此值。</para>
/// </summary>
public long MaxTotalLogSize { get; set; } = 1024L * 1024 * 1024;
}
}

View File

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

View File

@@ -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;
/// <summary>
/// 更新实际端口
/// </summary>
/// <param name="port"></param>
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;
}
/// <summary>
/// 解析端口配置
/// </summary>
/// <param name="config"></param>
/// <param name="value"></param>
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;
}
}
/// <summary>
/// 解析 Seq URI
/// </summary>
/// <param name="rawValue"></param>
/// <returns></returns>
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;
}
}
/// <summary>
/// 从 AppId 中解析 ID
/// </summary>
/// <param name="appId"></param>
/// <returns></returns>
private static int ParseIdFromAppId(string appId)
{
if (string.IsNullOrWhiteSpace(appId)) return 1;

View File

@@ -1,4 +1,7 @@
namespace SHH.CameraSdk;
using Ayay.SerilogLogs;
using Serilog;
namespace SHH.CameraSdk;
/// <summary>
/// [驱动支持层] 海康 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

View File

@@ -15,6 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="OpenCvSharp4" Version="4.11.0.20250507" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.11.0.20250507" />
@@ -22,6 +23,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj" />
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />
</ItemGroup>

View File

@@ -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
/// <summary>
/// 解析参数并初始化 Serilog
/// </summary>
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<string, Serilog.Events.LogEventLevel>
{
// 确保 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
/// <summary>
/// 统一的资源释放与关闭方法
/// </summary>
/// <summary>
/// 统一的资源释放与关闭方法
/// </summary>
/// <param name="reason">退出原因</param>
/// <param name="exitCode">退出码 (0=正常, 非0=异常)</param>
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
/// <summary>
/// 扫描可用端口
/// </summary>
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
/// <summary>
/// 检查端口是否可用
/// </summary>
/// <param name="port"></param>
/// <returns></returns>
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
/// <summary>
/// 硬件预热
/// </summary>
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
/// <summary>
/// 向网关注册实例
/// </summary>
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
}

View File

@@ -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
{
/// <summary>
/// 主程序
/// </summary>
/// <param name="args"></param>
/// <returns></returns>
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<ProcessingConfigManager>();
builder.Services.AddSingleton(sp => new ImageScaleCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
builder.Services.AddSingleton(sp => new ImageEnhanceCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
builder.Services.AddHostedService<PipelineConfigurator>();
// ★ 核心改动:一行代码注册所有业务 (SDK, Workers, gRPC, 视频流)
builder.Services.AddCameraBusinessServices(config, sysLog);
// 接入 SDK 核心逻辑
builder.Services.AddCameraSdk(config.NumericId);
builder.Services.AddHostedService<CameraEngineWorker>();
// ★ 注册 gRPC 版本的状态监控工作者 (不讲道理,直接注册)
builder.Services.AddHostedService<DeviceStatusHandler>();
builder.Services.AddHostedService<ParentProcessSentinel>();
builder.Services.AddHostedService<GatewayService>();
// =============================================================
// 5. 视频流 Target 注册 (gRPC 模式)
// =============================================================
var netTargets = new List<StreamTarget>();
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<IEnumerable<StreamTarget>>(netTargets);
builder.Services.AddHostedService<ImageMonitorController>();
// 为每个 Target 绑定一个 gRPC 流发送者
foreach (var target in netTargets)
{
builder.Services.AddHostedService(sp =>
new GrpcSenderWorker(target, sp.GetRequiredService<ILogger<GrpcSenderWorker>>()));
}
// 注册指令分发 (不再使用 NetMQ 的 CommandClientWorker)
builder.Services.AddSingleton<InterceptorPipeline>();
builder.Services.AddSingleton<CommandDispatcher>();
builder.Services.AddSingleton<ICommandHandler, DeviceConfigHandler>();
builder.Services.AddSingleton<ICommandHandler, RemoveCameraHandler>();
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);
}
/// <summary>
/// 激活单例并启动相机管理器
/// </summary>
static async Task StartBusinessLogic(WebApplication app)
/// <param name="app"></param>
/// <param name="logger"></param>
static async Task StartBusinessLogic(WebApplication app, Serilog.ILogger logger)
{
var manager = app.Services.GetRequiredService<CameraManager>();
// 激活哨兵
_ = app.Services.GetRequiredService<ConnectivitySentinel>();
await manager.StartAsync();
Console.WriteLine("[System] 核心业务逻辑已激活。");
Console.WriteLine("[System] 核心业务逻辑已激活。");
}
}
/// <summary>
/// 注册 Web API 支持
/// </summary>
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<UserActionFilter>();
})
.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" });
});
}
}
/*
🚀 启动/发布 程序启动、服务预热、开始监听
🏁 结束/终点 批量任务全部完成、程序正常退出
🔄 重试/循环 正在重试连接、定时任务触发、同步数据中
⏳ 等待/耗时 长耗时操作开始、排队中
💤 休眠/闲置 线程挂起、服务进入待机模式
🌐 网络/HTTP HTTP 请求、API 调用、Web 服务
🔌 连接 数据库连接建立、Socket 连接
📡 信号/广播 发送 MQ 消息、广播通知、取流
💾 存储/磁盘 写入文件、数据库落盘、缓存读写
🔒 安全/锁 加密、解密、登录成功、获取分布式锁
⚙️ 配置/系统 加载配置、系统底层操作
🐞 Bug/调试 捕捉到的异常、临时调试信息
🧪 测试/实验 单元测试环境、灰度测试代码
🔍 搜索/检查 查询数据库、检查文件是否存在
💡 提示/发现 逻辑分支提示、参数值打印
🔴 红:致命/严重 (Fatal/Error)
🟡 黄:警告 (Warning)
🟢 绿:正常/成功 (Info/Success)
🔵 蓝:数据/网络 (Data/Network)
⚪ 灰:细节/忽略 (Debug/Verbose)
✅ Check Mark Button \u{2705} &#x2705;
🆗 OK Button \u{1F197} &#x1F197;
🔚 END Arrow (逻辑结束) \u{1F51A} &#x1F51A;
💯 Hundred Points (完美结束) \u{1F4AF} &#x1F4AF;
🛑 Stop Sign (最强提示) \u{1F6D1} &#x1F6D1;
⛔ No Entry (禁止/中断) \u{26D4} &#x26D4;
🚫 Prohibited (非法操作终止) \u{1F6AB} &#x1F6AB;
⏹️ Stop Button (播放器风格) \u{23F9} &#x23F9;
❌ Cross Mark (任务失败结束) \u{274C} &#x274C;
💀 Skull (进程被 Kill) \u{1F480} &#x1F480;
⚰️ Coffin (彻底销毁) \u{26B0} &#x26B0;
👻 Ghost (变成孤儿进程) \u{1F47B} &#x1F47B;
*/

View File

@@ -22,6 +22,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj" />
<ProjectReference Include="..\SHH.CameraSdk\SHH.CameraSdk.csproj" />
<ProjectReference Include="..\SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj" />
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />

View File

@@ -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
/// <summary>
/// [聚合] 注册所有核心业务 (包含逻辑处理、SDK、Gateway、视频流)
/// </summary>
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
/// <summary>
/// 集中注册 SDK、Workers、Gateway 等纯业务服务
/// </summary>
private static void AddBusinessServices(this IServiceCollection services, ServiceConfig config)
{
// 基础单例
services.AddSingleton(config);
services.AddSingleton<ProcessingConfigManager>();
// 图像处理集群
services.AddSingleton(sp => new ImageScaleCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
services.AddSingleton(sp => new ImageEnhanceCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
services.AddHostedService<PipelineConfigurator>();
// SDK 与核心引擎
services.AddCameraSdk(config.NumericId);
services.AddHostedService<CameraEngineWorker>();
// 监控与网关
services.AddHostedService<DeviceStatusHandler>();
services.AddHostedService<ParentProcessSentinel>();
services.AddHostedService<GatewayService>();
// 指令分发系统
services.AddSingleton<InterceptorPipeline>();
services.AddSingleton<CommandDispatcher>();
services.AddSingleton<ICommandHandler, DeviceConfigHandler>();
services.AddSingleton<ICommandHandler, RemoveCameraHandler>();
}
#endregion
#region AddStreamTargets
/// <summary>
/// 注册视频流目标 (StreamTargets & GrpcSender)
/// </summary>
private static void AddStreamTargets(this IServiceCollection services, ServiceConfig config, Serilog.ILogger logger)
{
var netTargets = new List<StreamTarget>();
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<IEnumerable<StreamTarget>>(netTargets);
services.AddHostedService<ImageMonitorController>();
// 动态注册 Sender Worker
foreach (var target in netTargets)
{
// 注意:这里需要使用 Microsoft.Extensions.Logging.ILogger 来适配构造函数
services.AddHostedService(sp =>
new GrpcSenderWorker(target, sp.GetRequiredService<ILogger<GrpcSenderWorker>>()));
}
}
#endregion
// ========================================================================
// 2. Web 支持与管道 (Program.cs 调用的另外两个方法)
// ========================================================================
#region AddWebSupport
/// <summary>
/// 注册 Controller, Swagger, Cors
/// </summary>
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<UserActionFilter>();
})
.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
/// <summary>
/// 配置 HTTP 中间件管道
/// </summary>
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
}