using Model; using Model.Dto.Inspection; using Model.Entity.Inspection; using Model.Mapper; using ORM; using Service.Interface; using SqlSugar; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace Service.Implement { /// /// 告警通知配置服务实现(渠道/通知规则/推送日志/值班排班/跨网发件箱) /// public class AlertNotifyService : IAlertNotifyService { private readonly IEnumerable _notifiers; public AlertNotifyService(IEnumerable notifiers) { _notifiers = notifiers; } #region 通知渠道 /// /// 渠道列表(Secret 脱敏,只返回 HasSecret 标记) /// public async Task>> GetChannelsAsync() { try { var list = await SqlSugarContext.DbContext.Queryable() .Where(x => x.IsDel == 0) .OrderBy(x => x.CreateTime, OrderByType.Desc) .ToListAsync(); return Result>.Success(list.ToDtoList()); } catch (Exception ex) { return Result>.Error("查询通知渠道失败", ex); } } /// /// 新增渠道 /// public async Task> AddChannelAsync(AlertNotifyChannelDto dto) { if (string.IsNullOrWhiteSpace(dto?.Name) || string.IsNullOrWhiteSpace(dto?.WebhookUrl)) { return Result.Error("渠道名称与 Webhook 地址不能为空"); } try { var entity = dto.ToEntity(); entity.Id = 0; // 防止前端误传 Id,新增一律由雪花生成 entity.CreateTime = DateTime.Now; await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync(); return Result.Success(true); } catch (Exception ex) { return Result.Error("新增通知渠道失败", ex); } } /// /// 修改渠道(Secret 传空表示保持原值不覆盖——出口已脱敏,前端编辑时不回传明文) /// public async Task> UpdateChannelAsync(AlertNotifyChannelDto dto) { var entity = dto?.ToEntity(); if (entity == null || entity.Id <= 0) { return Result.Error("渠道 Id 无效"); } try { var updater = SqlSugarContext.DbContext.Updateable() .SetColumns(x => new AlertNotifyChannelEntity { ChannelType = entity.ChannelType, Name = entity.Name, WebhookUrl = entity.WebhookUrl, PushMode = entity.PushMode, IsEnabled = entity.IsEnabled, Remark = entity.Remark }); // Secret 显式传值时才覆盖(空表示不修改) if (!string.IsNullOrWhiteSpace(entity.Secret)) { updater = updater.SetColumns(x => x.Secret == entity.Secret); } var rows = await updater.Where(x => x.Id == entity.Id && x.IsDel == 0).ExecuteCommandAsync(); return Result.Success(rows > 0); } catch (Exception ex) { return Result.Error("修改通知渠道失败", ex); } } /// /// 删除渠道(软删除) /// public async Task> DeleteChannelAsync(long id) { try { var rows = await SqlSugarContext.DbContext.Updateable() .SetColumns(x => x.IsDel == 1) .Where(x => x.Id == id) .ExecuteCommandAsync(); return Result.Success(rows > 0); } catch (Exception ex) { return Result.Error("删除通知渠道失败", ex); } } /// /// 渠道测试推送(发送测试消息验证 Webhook 配置,结果写入推送日志) /// public async Task> TestSendAsync(long channelId) { try { var channel = await SqlSugarContext.DbContext.Queryable() .Where(x => x.Id == channelId && x.IsDel == 0) .FirstAsync(); if (channel == null) { return Result.Error("渠道不存在或已被删除"); } var notifier = _notifiers.FirstOrDefault(n => n.ChannelType == channel.ChannelType); if (notifier == null) { return Result.Error($"暂不支持的渠道类型: {channel.ChannelType}"); } var message = new AlertNotifyMessage { Title = "【测试】设备告警通知", Content = $"这是一条来自 IOT 设备管理平台的测试消息。\n渠道: {channel.Name}\n时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}" }; NotifyResult sendResult; if (channel.PushMode == NotifyPushModeEnum.Outbox) { // Outbox 模式:测试消息也走发件箱,由 AlertBridge 实际推送 await InsertOutboxAsync(0, channel, message, new List()); sendResult = NotifyResult.Ok(); } else { sendResult = await notifier.SendAsync(message, channel.WebhookUrl, channel.Secret); } await InsertLogAsync(0, channel, message.Title + " " + message.Content, sendResult, 0); return sendResult.Success ? Result.Success(true) : Result.Error($"测试推送失败: {sendResult.ErrorMsg}"); } catch (Exception ex) { return Result.Error("测试推送失败", ex); } } #endregion #region 通知规则 /// /// 规则列表 /// public async Task>> GetRulesAsync() { try { var list = await SqlSugarContext.DbContext.Queryable() .Where(x => x.IsDel == 0) .OrderBy(x => x.AlertLevel, OrderByType.Desc) .OrderBy(x => x.CreateTime, OrderByType.Desc) .ToListAsync(); return Result>.Success(list.ToDtoList()); } catch (Exception ex) { return Result>.Error("查询通知规则失败", ex); } } /// /// 新增规则 /// public async Task> AddRuleAsync(AlertNotifyRuleDto dto) { if (string.IsNullOrWhiteSpace(dto?.RuleName)) { return Result.Error("规则名称不能为空"); } if (dto.ChannelId == null || !long.TryParse(dto.ChannelId, out var channelId) || channelId <= 0) { return Result.Error("请选择有效的通知渠道"); } try { var entity = dto.ToEntity(); entity.Id = 0; entity.CreateTime = DateTime.Now; await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync(); return Result.Success(true); } catch (Exception ex) { return Result.Error("新增通知规则失败", ex); } } /// /// 修改规则 /// public async Task> UpdateRuleAsync(AlertNotifyRuleDto dto) { var entity = dto?.ToEntity(); if (entity == null || entity.Id <= 0) { return Result.Error("规则 Id 无效"); } try { var rows = await SqlSugarContext.DbContext.Updateable(entity) .IgnoreColumns(x => new { x.CreateTime, x.IsDel }) .ExecuteCommandAsync(); return Result.Success(rows > 0); } catch (Exception ex) { return Result.Error("修改通知规则失败", ex); } } /// /// 删除规则(软删除) /// public async Task> DeleteRuleAsync(long id) { try { var rows = await SqlSugarContext.DbContext.Updateable() .SetColumns(x => x.IsDel == 1) .Where(x => x.Id == id) .ExecuteCommandAsync(); return Result.Success(rows > 0); } catch (Exception ex) { return Result.Error("删除通知规则失败", ex); } } /// /// 启用/停用规则 /// public async Task> SetRuleEnabledAsync(long id, bool enabled) { try { var rows = await SqlSugarContext.DbContext.Updateable() .SetColumns(x => x.IsEnabled == enabled) .Where(x => x.Id == id) .ExecuteCommandAsync(); return Result.Success(rows > 0); } catch (Exception ex) { return Result.Error("更新规则启用状态失败", ex); } } #endregion #region 推送日志 /// /// 推送日志分页(可按成功状态/渠道筛选) /// public async Task>> GetLogsPagedAsync(int pageIndex, int pageSize, RefAsync total, bool? success, long channelId = 0) { try { var list = await SqlSugarContext.DbContext.Queryable() .Where(x => x.IsDel == 0) .WhereIF(success.HasValue, x => x.Success == success!.Value) .WhereIF(channelId > 0, x => x.ChannelId == channelId) .OrderBy(x => x.NotifyTime, OrderByType.Desc) .ToPageListAsync(pageIndex, pageSize, total); return Result>.Success(list.ToDtoList()); } catch (Exception ex) { return Result>.Error("查询推送日志失败", ex); } } /// /// 写入推送日志(内部公共方法,Worker 也复用) /// internal async Task InsertLogAsync(long alertId, AlertNotifyChannelEntity channel, string content, NotifyResult result, int retryCount) { try { await SqlSugarContext.DbContext.Insertable(new AlertNotifyLogEntity { AlertId = alertId, ChannelType = channel.ChannelType, ChannelId = channel.Id, Target = MaskUrl(channel.WebhookUrl), Content = content.Length > 1000 ? content.Substring(0, 1000) : content, Success = result.Success, ErrorMsg = result.ErrorMsg, RetryCount = retryCount, NotifyTime = DateTime.Now, CreateTime = DateTime.Now }).ExecuteCommandAsync(); } catch { // 日志写入失败不影响主流程 } } /// /// Webhook 地址脱敏(保留主机名与路径结构,隐藏末段令牌与查询参数) /// 飞书令牌在路径末段(/hook/{token})、钉钉在 access_token、企微在 key,均需遮蔽,避免日志泄露密钥 /// internal static string MaskUrl(string url) { if (string.IsNullOrWhiteSpace(url)) return url; try { var uri = new Uri(url); var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); // 保留除末段外的路径,末段(如飞书 hook 令牌)用 *** 代替;查询串一律丢弃 string maskedPath = segments.Length > 1 ? "/" + string.Join("/", segments.Take(segments.Length - 1)) + "/***" : "/***"; return $"{uri.Scheme}://{uri.Host}{maskedPath}"; } catch { return url.Length > 60 ? url.Substring(0, 60) + "..." : url; } } #endregion #region 值班排班 /// /// 值班排班列表(按日期范围,缺省查本月) /// public async Task>> GetDutiesAsync(DateTime? startDate, DateTime? endDate) { try { var start = startDate ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); var end = endDate ?? start.AddMonths(1).AddDays(-1); var list = await SqlSugarContext.DbContext.Queryable() .Where(x => x.IsDel == 0 && x.DutyDate >= start.Date && x.DutyDate <= end.Date) .OrderBy(x => x.DutyDate, OrderByType.Asc) .ToListAsync(); return Result>.Success(list.ToDtoList()); } catch (Exception ex) { return Result>.Error("查询值班排班失败", ex); } } /// /// 新增排班 /// public async Task> AddDutyAsync(DutyRosterDto dto) { if (string.IsNullOrWhiteSpace(dto?.PersonName)) { return Result.Error("值班人不能为空"); } try { var entity = dto.ToEntity(); entity.Id = 0; entity.CreateTime = DateTime.Now; await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync(); return Result.Success(true); } catch (Exception ex) { return Result.Error("新增值班排班失败", ex); } } /// /// 修改排班 /// public async Task> UpdateDutyAsync(DutyRosterDto dto) { var entity = dto?.ToEntity(); if (entity == null || entity.Id <= 0) { return Result.Error("排班 Id 无效"); } try { var rows = await SqlSugarContext.DbContext.Updateable(entity) .IgnoreColumns(x => new { x.CreateTime, x.IsDel }) .ExecuteCommandAsync(); return Result.Success(rows > 0); } catch (Exception ex) { return Result.Error("修改值班排班失败", ex); } } /// /// 删除排班(软删除) /// public async Task> DeleteDutyAsync(long id) { try { var rows = await SqlSugarContext.DbContext.Updateable() .SetColumns(x => x.IsDel == 1) .Where(x => x.Id == id) .ExecuteCommandAsync(); return Result.Success(rows > 0); } catch (Exception ex) { return Result.Error("删除值班排班失败", ex); } } /// /// 查询指定日期的值班人 /// public async Task GetDutyByDateAsync(DateTime date) { return await SqlSugarContext.DbContext.Queryable() .Where(x => x.IsDel == 0 && x.DutyDate == date.Date) .FirstAsync(); } #endregion #region 跨网发件箱(AlertBridge 对接) /// /// 写入推送日志(供 AlertNotifyWorker 直连推送后复用) /// public async Task WriteLogAsync(long alertId, long channelId, string content, bool success, string? errorMsg, int retryCount) { var channel = await SqlSugarContext.DbContext.Queryable() .Where(x => x.Id == channelId) .FirstAsync(); if (channel == null) return; await InsertLogAsync(alertId, channel, content, success ? NotifyResult.Ok() : NotifyResult.Fail(errorMsg ?? "未知错误"), retryCount); } /// /// 写入跨网发件箱(供 AlertNotifyWorker 的 Outbox 模式复用) /// public async Task EnqueueOutboxAsync(long alertId, long channelId, AlertNotifyMessage message, List atMobiles) { var channel = await SqlSugarContext.DbContext.Queryable() .Where(x => x.Id == channelId && x.IsDel == 0) .FirstAsync(); if (channel == null) return; await InsertOutboxAsync(alertId, channel, message, atMobiles); } /// /// 拉取待推送发件箱(先查后标记;单跳板机实例场景足够,多实例并发时可改用数据库锁) /// public async Task>> PullOutboxAsync(int batch) { try { if (batch <= 0 || batch > 100) batch = 20; var pending = await SqlSugarContext.DbContext.Queryable() .Where(x => x.IsDel == 0 && x.Status == OutboxStatusEnum.Pending) .OrderBy(x => x.CreateTime, OrderByType.Asc) .Take(batch) .ToListAsync(); if (pending.Count == 0) { return Result>.Success(new List()); } // 标记已拉取(仅更新仍为 Pending 的记录,防止重复拉取) var ids = pending.Select(x => x.Id).ToList(); await SqlSugarContext.DbContext.Updateable() .SetColumns(x => new AlertOutboxEntity { Status = OutboxStatusEnum.Pulled, PullTime = DateTime.Now }) .Where(x => ids.Contains(x.Id) && x.Status == OutboxStatusEnum.Pending) .ExecuteCommandAsync(); return Result>.Success(pending.ToDtoList()); } catch (Exception ex) { return Result>.Error("拉取发件箱失败", ex); } } /// /// 回写发件箱推送结果(失败累加重试次数;结果同步写推送日志便于页面追溯) /// public async Task> ReportOutboxResultAsync(long id, OutboxResultDto dto) { try { var item = await SqlSugarContext.DbContext.Queryable() .Where(x => x.Id == id && x.IsDel == 0) .FirstAsync(); if (item == null) { return Result.Error("发件箱记录不存在"); } var status = dto?.Success == true ? OutboxStatusEnum.Success : OutboxStatusEnum.Failed; // 表达式树不支持 ?. 空传播,先提取局部变量 bool success = dto?.Success == true; string? resultMsg = dto?.ResultMsg; await SqlSugarContext.DbContext.Updateable() .SetColumns(x => new AlertOutboxEntity { Status = status, FinishTime = DateTime.Now, ResultMsg = resultMsg, RetryCount = success ? x.RetryCount : x.RetryCount + 1 }) .Where(x => x.Id == id) .ExecuteCommandAsync(); // 推送日志(渠道信息回查,失败忽略) var channel = await SqlSugarContext.DbContext.Queryable() .Where(x => x.Id == item.ChannelId) .FirstAsync(); if (channel != null) { await InsertLogAsync(item.AlertId, channel, $"[跨网中转] {(dto?.Success == true ? "推送成功" : "推送失败")}", dto?.Success == true ? NotifyResult.Ok() : NotifyResult.Fail(dto?.ResultMsg ?? "未知错误"), item.RetryCount); } return Result.Success(true); } catch (Exception ex) { return Result.Error("回写发件箱结果失败", ex); } } /// /// 写入发件箱(Worker 的 Outbox 模式与渠道测试推送共用;Payload 自包含全部推送要素) /// internal async Task InsertOutboxAsync(long alertId, AlertNotifyChannelEntity channel, AlertNotifyMessage message, List atMobiles) { var payload = new AlertOutboxPayload { ChannelType = (int)channel.ChannelType, WebhookUrl = channel.WebhookUrl, Secret = channel.Secret, Title = message.Title, Content = message.Content, AtMobiles = atMobiles }; await SqlSugarContext.DbContext.Insertable(new AlertOutboxEntity { AlertId = alertId, ChannelId = channel.Id, Payload = System.Text.Json.JsonSerializer.Serialize(payload), Status = OutboxStatusEnum.Pending, CreateTime = DateTime.Now }).ExecuteCommandAsync(); } #endregion } }