用了统一存储,之前显示系统日志的读方法需要修改
This commit is contained in:
@@ -5,25 +5,24 @@ namespace SHH.CameraSdk;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 视频源实时状态监控 API 控制器
|
/// 视频源实时状态监控 API 控制器
|
||||||
/// 核心功能:提供相机设备遥测数据查询、单设备详情查询、设备截图获取接口
|
/// 核心功能:提供相机设备遥测数据查询、单设备详情查询、系统日志查询
|
||||||
/// 适用场景:Web 监控大屏、移动端状态查询、第三方系统集成
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/monitor")] // [建议] 显式指定路由为小写,确保与前端 ${API}/monitor/... 匹配
|
||||||
public class MonitorController : ControllerBase
|
public class MonitorController : ControllerBase
|
||||||
{
|
{
|
||||||
#region --- 依赖注入 (Dependency Injection) ---
|
#region --- 依赖注入 (Dependency Injection) ---
|
||||||
|
|
||||||
/// <summary> 相机管理器实例:提供设备状态与遥测数据访问能力 </summary>
|
|
||||||
private readonly CameraManager _cameraManager;
|
private readonly CameraManager _cameraManager;
|
||||||
|
private readonly IStorageService _storage; // [新增] 存储服务引用
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 构造函数:通过依赖注入获取 CameraManager 实例
|
/// 构造函数:注入 CameraManager 和 IStorageService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cameraManager">相机管理器</param>
|
public MonitorController(CameraManager cameraManager, IStorageService storage)
|
||||||
public MonitorController(CameraManager cameraManager)
|
|
||||||
{
|
{
|
||||||
_cameraManager = cameraManager;
|
_cameraManager = cameraManager;
|
||||||
|
_storage = storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -31,14 +30,9 @@ public class MonitorController : ControllerBase
|
|||||||
#region --- API 接口定义 (API Endpoints) ---
|
#region --- API 接口定义 (API Endpoints) ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取全量相机实时遥测数据快照(支持跨域)
|
/// 获取全量相机实时遥测数据快照
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 返回数据包含:设备ID、名称、IP地址、运行状态、在线状态、实时FPS、累计帧数、健康度评分、最后错误信息
|
|
||||||
/// 适用场景:监控大屏首页数据看板
|
/// 适用场景:监控大屏首页数据看板
|
||||||
/// [cite: 191, 194]
|
/// </summary>
|
||||||
/// </remarks>
|
|
||||||
/// <returns>200 OK + 遥测数据列表</returns>
|
|
||||||
[HttpGet("dashboard")]
|
[HttpGet("dashboard")]
|
||||||
public IActionResult GetDashboard()
|
public IActionResult GetDashboard()
|
||||||
{
|
{
|
||||||
@@ -49,18 +43,13 @@ public class MonitorController : ControllerBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取指定相机的详细运行指标
|
/// 获取指定相机的详细运行指标
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">相机设备唯一标识</param>
|
|
||||||
/// <returns>200 OK + 设备详情 | 404 Not Found</returns>
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
public IActionResult GetDeviceDetail(long id)
|
public IActionResult GetDeviceDetail(long id)
|
||||||
{
|
{
|
||||||
// 查询指定设备
|
|
||||||
var device = _cameraManager.GetDevice(id);
|
var device = _cameraManager.GetDevice(id);
|
||||||
// 设备不存在返回 404
|
|
||||||
if (device == null) return NotFound($"设备 ID: {id} 不存在");
|
if (device == null) return NotFound($"设备 ID: {id} 不存在");
|
||||||
|
|
||||||
// 构造设备详情返回对象
|
return Ok(new
|
||||||
var deviceDetail = new
|
|
||||||
{
|
{
|
||||||
device.Id,
|
device.Id,
|
||||||
device.Status,
|
device.Status,
|
||||||
@@ -69,53 +58,60 @@ public class MonitorController : ControllerBase
|
|||||||
device.TotalFrames,
|
device.TotalFrames,
|
||||||
device.Config.Name,
|
device.Config.Name,
|
||||||
device.Config.IpAddress
|
device.Config.IpAddress
|
||||||
};
|
});
|
||||||
|
|
||||||
return Ok(deviceDetail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取指定相机的实时截图
|
/// 获取指定相机的实时截图
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">相机设备唯一标识</param>
|
|
||||||
/// <returns>200 OK + JPEG 图片流 | 504 Gateway Timeout</returns>
|
|
||||||
[HttpGet("snapshot/{id}")]
|
[HttpGet("snapshot/{id}")]
|
||||||
public async Task<IActionResult> GetSnapshot(long id)
|
public async Task<IActionResult> GetSnapshot(long id)
|
||||||
{
|
{
|
||||||
// 调用截图协调器获取实时截图,设置 2 秒超时
|
// 假设您有 SnapshotCoordinator 单例,此处保留原逻辑
|
||||||
// 超时保护:避免 HTTP 线程因设备异常长时间挂起
|
|
||||||
var imageBytes = await SnapshotCoordinator.Instance.RequestSnapshotAsync(id, 2000);
|
var imageBytes = await SnapshotCoordinator.Instance.RequestSnapshotAsync(id, 2000);
|
||||||
|
|
||||||
// 截图超时或设备无响应,返回 504 超时状态码
|
|
||||||
if (imageBytes == null)
|
if (imageBytes == null)
|
||||||
{
|
{
|
||||||
return StatusCode(StatusCodes.Status504GatewayTimeout, "截图请求超时或设备未响应");
|
return StatusCode(StatusCodes.Status504GatewayTimeout, "截图请求超时或设备未响应");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回 JPEG 格式图片流,支持浏览器直接预览
|
|
||||||
return File(imageBytes, "image/jpeg");
|
return File(imageBytes, "image/jpeg");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取指定相机的诊断信息(含审计日志)
|
/// 获取指定相机的深度诊断信息(包含持久化的审计日志)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">相机设备唯一标识</param>
|
|
||||||
/// <returns>200 OK + 诊断信息 | 404 Not Found</returns>
|
|
||||||
[HttpGet("diagnose/{id}")]
|
[HttpGet("diagnose/{id}")]
|
||||||
public IActionResult GetDeviceDiagnostic(long id)
|
public async Task<IActionResult> GetDeviceDiagnostic(long id)
|
||||||
{
|
{
|
||||||
var device = _cameraManager.GetDevice(id);
|
var device = _cameraManager.GetDevice(id);
|
||||||
if (device == null) return NotFound();
|
if (device == null) return NotFound("设备不存在");
|
||||||
|
|
||||||
|
// [修正] 改为从 StorageService 读取文件日志
|
||||||
|
// 这样即使重启程序,历史日志也能查到
|
||||||
|
var logs = await _storage.GetDeviceLogsAsync((int)id, 50);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
// 基础信息
|
||||||
Id = device.Id,
|
Id = device.Id,
|
||||||
Status = device.Status.ToString(),
|
Status = device.Status.ToString(),
|
||||||
RealFps = device.RealFps,
|
RealFps = device.RealFps,
|
||||||
TotalFrames = device.TotalFrames,
|
TotalFrames = device.TotalFrames,
|
||||||
// 关键:将 BaseVideoSource 中的日志列表返回给前端
|
|
||||||
// 注意:属性名 AuditLogs 会被序列化为 auditLogs (首字母小写),符合前端预期
|
// 实时状态
|
||||||
AuditLogs = device.GetAuditLogs()
|
BasicInfo = device.Config,
|
||||||
|
RealTimeStats = new
|
||||||
|
{
|
||||||
|
device.RealFps,
|
||||||
|
device.TotalFrames,
|
||||||
|
device.IsPhysicalOnline,
|
||||||
|
device.Width,
|
||||||
|
device.Height
|
||||||
|
},
|
||||||
|
|
||||||
|
// [关键] 持久化日志
|
||||||
|
AuditLogs = logs
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,26 +121,12 @@ public class MonitorController : ControllerBase
|
|||||||
/// 获取系统操作日志(读取最新的 50 条)
|
/// 获取系统操作日志(读取最新的 50 条)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("system-logs")]
|
[HttpGet("system-logs")]
|
||||||
public IActionResult GetSystemLogs()
|
public async Task<IActionResult> GetSystemLogs()
|
||||||
{
|
{
|
||||||
try
|
// [修正] 彻底废弃手动读文件,改用 Service
|
||||||
{
|
// Service 内部会自动处理锁、路径 (App_Data/Process_X/system.log) 和异常
|
||||||
var logPath = "user_actions.log";
|
var logs = await _storage.GetSystemLogsAsync(50);
|
||||||
if (!System.IO.File.Exists(logPath))
|
|
||||||
{
|
|
||||||
return Ok(new List<string> { "暂无操作记录" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取文件 -> 取最后50行 -> 倒序排列(最新在前)
|
|
||||||
var logs = System.IO.File.ReadLines(logPath)
|
|
||||||
.TakeLast(50)
|
|
||||||
.Reverse()
|
|
||||||
.ToList();
|
|
||||||
return Ok(logs);
|
return Ok(logs);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, $"读取日志失败: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ public class CameraManager : IDisposable, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
var device = CreateDeviceInstance(config);
|
var device = CreateDeviceInstance(config);
|
||||||
// 默认设为运行状态,让协调器稍后去连接
|
// 默认设为运行状态,让协调器稍后去连接
|
||||||
device.IsRunning = true;
|
//device.IsRunning = true;
|
||||||
_cameraPool.TryAdd(config.Id, device);
|
_cameraPool.TryAdd(config.Id, device);
|
||||||
loadedCount++;
|
loadedCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +1,165 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace SHH.CameraSdk
|
namespace SHH.CameraSdk;
|
||||||
|
|
||||||
|
public class FileStorageService : IStorageService
|
||||||
{
|
{
|
||||||
public class FileStorageService : IStorageService
|
|
||||||
{
|
|
||||||
public int ProcessId { get; }
|
public int ProcessId { get; }
|
||||||
private readonly string _baseDir;
|
private readonly string _baseDir;
|
||||||
private readonly string _devicesPath;
|
private readonly string _devicesPath;
|
||||||
private readonly SemaphoreSlim _fileLock = new SemaphoreSlim(1, 1);
|
private readonly string _systemLogPath; // 系统日志路径
|
||||||
|
private readonly string _logsDir; // 设备日志文件夹
|
||||||
|
|
||||||
// [关键修复] 配置序列化选项,解决“只存属性不存字段”的问题
|
// 【关键优化】双锁分离:配置读写和日志读写互不干扰
|
||||||
|
private readonly SemaphoreSlim _configLock = new SemaphoreSlim(1, 1);
|
||||||
|
private readonly SemaphoreSlim _logLock = new SemaphoreSlim(1, 1);
|
||||||
|
|
||||||
|
// JSON 配置 (保持不变)
|
||||||
private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
|
private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
WriteIndented = true, // 格式化 JSON,让人眼可读
|
WriteIndented = true,
|
||||||
IncludeFields = true, // [核心] 允许序列化 public int Id; 这种字段
|
IncludeFields = true,
|
||||||
PropertyNameCaseInsensitive = true, // 忽略大小写差异
|
PropertyNameCaseInsensitive = true,
|
||||||
NumberHandling = JsonNumberHandling.AllowReadingFromString // 允许 "8000" 读为 int 8000
|
NumberHandling = JsonNumberHandling.AllowReadingFromString
|
||||||
};
|
};
|
||||||
|
|
||||||
public FileStorageService(int processId)
|
public FileStorageService(int processId)
|
||||||
{
|
{
|
||||||
ProcessId = processId;
|
ProcessId = processId;
|
||||||
|
|
||||||
|
// 目录结构:
|
||||||
|
// App_Data/Process_1/
|
||||||
|
// ├── devices.json
|
||||||
|
// ├── system.log
|
||||||
|
// └── logs/
|
||||||
|
// ├── device_101.log
|
||||||
|
// └── device_102.log
|
||||||
|
|
||||||
_baseDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "App_Data", $"Process_{processId}");
|
_baseDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "App_Data", $"Process_{processId}");
|
||||||
_devicesPath = Path.Combine(_baseDir, "devices.json");
|
_devicesPath = Path.Combine(_baseDir, "devices.json");
|
||||||
|
_systemLogPath = Path.Combine(_baseDir, "system.log");
|
||||||
|
_logsDir = Path.Combine(_baseDir, "logs");
|
||||||
|
|
||||||
if (!Directory.Exists(_baseDir)) Directory.CreateDirectory(_baseDir);
|
if (!Directory.Exists(_baseDir)) Directory.CreateDirectory(_baseDir);
|
||||||
|
if (!Directory.Exists(_logsDir)) Directory.CreateDirectory(_logsDir);
|
||||||
|
|
||||||
Console.WriteLine($"[Storage] 路径: {_devicesPath}");
|
Console.WriteLine($"[Storage] 服务就绪 | 日志路径: {_systemLogPath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// 1. 设备配置管理 (使用 _configLock)
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
public async Task SaveDevicesAsync(IEnumerable<VideoSourceConfig> configs)
|
public async Task SaveDevicesAsync(IEnumerable<VideoSourceConfig> configs)
|
||||||
{
|
{
|
||||||
await _fileLock.WaitAsync();
|
await _configLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// [调试] 打印正在保存的数量,确保 Manager 传过来的数据是对的
|
|
||||||
// Console.WriteLine($"[Debug] 正在保存 {configs.Count()} 台设备...");
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(configs, _jsonOptions);
|
var json = JsonSerializer.Serialize(configs, _jsonOptions);
|
||||||
await File.WriteAllTextAsync(_devicesPath, json);
|
await File.WriteAllTextAsync(_devicesPath, json);
|
||||||
|
|
||||||
// [调试] 打印部分 JSON 内容,验证是否为空对象 "{}"
|
|
||||||
// if (json.Length < 200) Console.WriteLine($"[Debug] JSON 内容: {json}");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[Storage] ❌ 保存配置失败: {ex.Message}");
|
Console.WriteLine($"[Storage] ❌ 保存配置失败: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
finally { _configLock.Release(); }
|
||||||
{
|
|
||||||
_fileLock.Release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<VideoSourceConfig>> LoadDevicesAsync()
|
public async Task<List<VideoSourceConfig>> LoadDevicesAsync()
|
||||||
{
|
{
|
||||||
if (!File.Exists(_devicesPath))
|
if (!File.Exists(_devicesPath)) return new List<VideoSourceConfig>();
|
||||||
{
|
|
||||||
Console.WriteLine("[Storage] ⚠️ 配置文件不存在,将使用空列表");
|
|
||||||
return new List<VideoSourceConfig>();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _fileLock.WaitAsync();
|
await _configLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = await File.ReadAllTextAsync(_devicesPath);
|
var json = await File.ReadAllTextAsync(_devicesPath);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(json)) return new List<VideoSourceConfig>();
|
if (string.IsNullOrWhiteSpace(json)) return new List<VideoSourceConfig>();
|
||||||
|
|
||||||
// [调试] 打印读取到的原始 JSON
|
|
||||||
// Console.WriteLine($"[Debug] 读取文件内容: {json.Substring(0, Math.Min(json.Length, 100))}...");
|
|
||||||
|
|
||||||
var list = JsonSerializer.Deserialize<List<VideoSourceConfig>>(json, _jsonOptions);
|
var list = JsonSerializer.Deserialize<List<VideoSourceConfig>>(json, _jsonOptions);
|
||||||
|
|
||||||
// 二次校验:如果读出来列表不为空,但 ID 全是 0,说明序列化还是没对上
|
|
||||||
if (list != null && list.Count > 0 && list[0].Id == 0 && list[0].Port == 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Storage] ⚠️ 警告:读取到设备,但字段似乎为空。请检查 VideoSourceConfig 是否使用了 private 属性?");
|
|
||||||
}
|
|
||||||
|
|
||||||
return list ?? new List<VideoSourceConfig>();
|
return list ?? new List<VideoSourceConfig>();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[Storage] ❌ 读取配置失败: {ex.Message}");
|
Console.WriteLine($"[Storage] ❌ 读取配置失败: {ex.Message}");
|
||||||
// 出错时返回空列表,不要抛出异常,否则 StartAsync 会崩溃
|
|
||||||
return new List<VideoSourceConfig>();
|
return new List<VideoSourceConfig>();
|
||||||
}
|
}
|
||||||
finally
|
finally { _configLock.Release(); }
|
||||||
{
|
|
||||||
_fileLock.Release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
// 日志部分 (保持空实现以免干扰)
|
// 2. 系统操作日志 (使用 _logLock)
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
public Task AppendSystemLogAsync(string action, string ip, string path) => Task.CompletedTask;
|
|
||||||
public Task<List<string>> GetSystemLogsAsync(int count) => Task.FromResult(new List<string>());
|
public async Task AppendSystemLogAsync(string action, string ip, string path)
|
||||||
public Task AppendDeviceLogAsync(int deviceId, string message) => Task.CompletedTask;
|
{
|
||||||
public Task<List<string>> GetDeviceLogsAsync(int deviceId, int count) => Task.FromResult(new List<string>());
|
// 格式: [时间] | IP | 动作 路径
|
||||||
|
var time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
|
var line = $"[{time}] | {ip} | {action} {path}";
|
||||||
|
|
||||||
|
await _logLock.WaitAsync(); // 等待日志锁
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 追加写入 (Async)
|
||||||
|
await File.AppendAllTextAsync(_systemLogPath, line + Environment.NewLine);
|
||||||
|
}
|
||||||
|
catch { /* 忽略日志写入错误,别崩了主程序 */ }
|
||||||
|
finally { _logLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetSystemLogsAsync(int count)
|
||||||
|
{
|
||||||
|
if (!File.Exists(_systemLogPath)) return new List<string> { "暂无日志" };
|
||||||
|
|
||||||
|
await _logLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 读取所有行 (如果日志文件非常大,这里建议优化为倒序读取,但几MB以内没问题)
|
||||||
|
var lines = await File.ReadAllLinesAsync(_systemLogPath);
|
||||||
|
|
||||||
|
// 取最后 N 行,并反转(让最新的显示在最上面)
|
||||||
|
return lines.TakeLast(count).Reverse().ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new List<string> { $"读取失败: {ex.Message}" };
|
||||||
|
}
|
||||||
|
finally { _logLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// 3. 设备审计日志 (使用 _logLock)
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
public async Task AppendDeviceLogAsync(int deviceId, string message)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_logsDir, $"device_{deviceId}.log");
|
||||||
|
var time = DateTime.Now.ToString("MM-dd HH:mm:ss");
|
||||||
|
var line = $"{time} > {message}";
|
||||||
|
|
||||||
|
await _logLock.WaitAsync(); // 复用日志锁,防止多文件同时IO导致磁盘抖动
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.AppendAllTextAsync(path, line + Environment.NewLine);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
finally { _logLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetDeviceLogsAsync(int deviceId, int count)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_logsDir, $"device_{deviceId}.log");
|
||||||
|
if (!File.Exists(path)) return new List<string>();
|
||||||
|
|
||||||
|
await _logLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = await File.ReadAllLinesAsync(path);
|
||||||
|
return lines.TakeLast(count).Reverse().ToList();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<string>();
|
||||||
|
}
|
||||||
|
finally { _logLock.Release(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user