Merge remote-tracking branch 'origin/master'(告警中心/通知模块 与 产品/物模型模块 合并,EntityMapper.cs 保留双方映射区域)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
using Model;
|
||||
using Model.Dto.Inspection;
|
||||
using Model.Entity.Inspection;
|
||||
using Model.Mapper;
|
||||
using ORM;
|
||||
using Service.Interface;
|
||||
using SqlSugar;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Service.Implement
|
||||
{
|
||||
/// <summary>
|
||||
/// 告警中心服务实现(全系统统一告警上报入口)
|
||||
/// 职责:默认内容生成 → 去重合并(防告警风暴)→ 落库 → 规则触发计数 → 通知队列入队
|
||||
/// </summary>
|
||||
public class AlertCenterService : IAlertCenterService
|
||||
{
|
||||
/// <summary>
|
||||
/// 合并窗口(分钟):窗口内同设备+同测点+同类型的活跃告警不新建记录,只累加触发次数。
|
||||
/// 后续可迁移到系统参数配置模块动态调整
|
||||
/// </summary>
|
||||
private const int MergeWindowMinutes = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 上报一条告警
|
||||
/// </summary>
|
||||
public async Task<Result<string>> RaiseAsync(AlertRaiseDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
{
|
||||
return Result<string>.Error("告警上报参数为空");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(dto.DeviceCode) && string.IsNullOrWhiteSpace(dto.Content))
|
||||
{
|
||||
return Result<string>.Error("设备编号与告警内容不能同时为空");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
|
||||
// ===== 去重合并:窗口内相同 设备+测点+类型 的活跃告警(未确认/已确认)只累加不新建 =====
|
||||
var active = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0)
|
||||
.Where(x => x.DeviceCode == dto.DeviceCode && x.Metric == dto.Metric && x.AlertType == dto.AlertType)
|
||||
.Where(x => x.AlertStatus == AlertStatusEnum.Unacknowledged || x.AlertStatus == AlertStatusEnum.Acknowledged)
|
||||
.Where(x => x.LastAlertTime >= now.AddMinutes(-MergeWindowMinutes))
|
||||
.FirstAsync();
|
||||
|
||||
if (active != null)
|
||||
{
|
||||
// 累加触发次数、刷新最近告警时间与当前值;级别取更高者(告警升级)
|
||||
var mergedLevel = active.AlertLevel > dto.AlertLevel ? active.AlertLevel : dto.AlertLevel;
|
||||
await SqlSugarContext.DbContext.Updateable<AlertMessageEntity>()
|
||||
.SetColumns(x => new AlertMessageEntity
|
||||
{
|
||||
TriggerCount = x.TriggerCount + 1,
|
||||
LastAlertTime = now,
|
||||
CurrentValue = dto.CurrentValue ?? x.CurrentValue,
|
||||
AlertLevel = mergedLevel
|
||||
})
|
||||
.Where(x => x.Id == active.Id)
|
||||
.ExecuteCommandAsync();
|
||||
|
||||
// 合并告警也入队(是否真正推送由 Worker 按通知规则静默期决定)
|
||||
AlertNotifyBus.Publish(active.Id, false);
|
||||
return Result<string>.Success(active.Id.ToString());
|
||||
}
|
||||
|
||||
// ===== 新告警:生成默认内容 → 落库 → 规则计数 → 入队 =====
|
||||
var entity = dto.ToEntity();
|
||||
if (string.IsNullOrWhiteSpace(entity.Content))
|
||||
{
|
||||
entity.Content = BuildDefaultContent(entity);
|
||||
}
|
||||
entity.CreateTime = now;
|
||||
entity.FirstAlertTime = now;
|
||||
entity.LastAlertTime = now;
|
||||
entity.TriggerCount = 1;
|
||||
entity.AlertStatus = AlertStatusEnum.Unacknowledged;
|
||||
|
||||
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
|
||||
|
||||
// 来源为规则引擎时,回写规则触发统计(与黄的规则引擎模块联动)
|
||||
if (entity.RuleId > 0)
|
||||
{
|
||||
await SqlSugarContext.DbContext.Updateable<AlertRuleEntity>()
|
||||
.SetColumns(x => new AlertRuleEntity { TriggerCount = x.TriggerCount + 1, LastAlarmTime = now })
|
||||
.Where(x => x.Id == entity.RuleId)
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
AlertNotifyBus.Publish(entity.Id, true);
|
||||
return Result<string>.Success(entity.Id.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<string>.Error("告警上报失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按内置模板生成默认告警内容(后续可由通知模板模块接管,见 TemplateCode 预留字段)
|
||||
/// </summary>
|
||||
private static string BuildDefaultContent(AlertMessageEntity e)
|
||||
{
|
||||
string device = string.IsNullOrWhiteSpace(e.DeviceName) ? e.DeviceCode ?? "未知设备" : $"{e.DeviceName}({e.DeviceCode})";
|
||||
string metric = string.IsNullOrWhiteSpace(e.Metric) ? "" : $" 测点[{e.Metric}]";
|
||||
string value = e.CurrentValue.HasValue ? $" 当前值 {e.CurrentValue.Value}" : "";
|
||||
string threshold = e.ThresholdValue.HasValue ? $",阈值 {e.ThresholdValue.Value}" : "";
|
||||
return $"{device}{metric} 触发告警:{value}{threshold}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,412 @@
|
||||
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.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Service.Implement
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息告警 服务实现
|
||||
/// 消息告警 服务实现(告警列表/闭环操作/统计分析)
|
||||
/// </summary>
|
||||
public class AlertMessageService : IAlertMessageService
|
||||
{
|
||||
// TODO: 实现 消息告警 相关方法
|
||||
#region 查询
|
||||
/// <summary>
|
||||
/// 分页查询告警(支持级别/状态/类型/来源/设备/关键字/时间范围筛选)
|
||||
/// </summary>
|
||||
public async Task<Result<List<AlertMessageDto>>> GetPagedAsync(int pageIndex, int pageSize, RefAsync<int> total,
|
||||
AlertLevelEnum? level, AlertStatusEnum? status, AlertTypeEnum? type, AlertSourceEnum? source,
|
||||
string? deviceCode, string? keyword, DateTime? startDate, DateTime? endDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0)
|
||||
.WhereIF(level.HasValue, x => x.AlertLevel == level!.Value)
|
||||
.WhereIF(status.HasValue, x => x.AlertStatus == status!.Value)
|
||||
.WhereIF(type.HasValue, x => x.AlertType == type!.Value)
|
||||
.WhereIF(source.HasValue, x => x.Source == source!.Value)
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(deviceCode), x => x.DeviceCode == deviceCode)
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(keyword),
|
||||
x => x.DeviceCode!.Contains(keyword!) || x.DeviceName!.Contains(keyword!) || x.Content!.Contains(keyword!))
|
||||
.WhereIF(startDate.HasValue, x => x.FirstAlertTime >= startDate!.Value)
|
||||
.WhereIF(endDate.HasValue, x => x.FirstAlertTime <= endDate!.Value)
|
||||
.OrderBy(x => x.LastAlertTime, OrderByType.Desc)
|
||||
.ToPageListAsync(pageIndex, pageSize, total);
|
||||
// total 保持为总记录数(前端分页组件需要)
|
||||
return Result<List<AlertMessageDto>>.Success(list.ToDtoList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<List<AlertMessageDto>>.Error("分页查询告警失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 告警详情
|
||||
/// </summary>
|
||||
public async Task<Result<AlertMessageDto>> GetByIdAsync(long id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.Id == id && x.IsDel == 0)
|
||||
.FirstAsync();
|
||||
if (entity == null)
|
||||
{
|
||||
return Result<AlertMessageDto>.Error("告警不存在或已被删除");
|
||||
}
|
||||
return Result<AlertMessageDto>.Success(entity.ToDto());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<AlertMessageDto>.Error("查询告警详情失败", ex);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 闭环操作
|
||||
/// <summary>
|
||||
/// 确认告警
|
||||
/// </summary>
|
||||
public Task<Result<bool>> AcknowledgeAsync(long id, AlertOperateDto dto)
|
||||
{
|
||||
return OperateAsync(id, dto, AlertStatusEnum.Acknowledged, setAck: true, setHandle: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理告警(未确认时同时补记确认信息,处理即视为已确认)
|
||||
/// </summary>
|
||||
public Task<Result<bool>> HandleAsync(long id, AlertOperateDto dto)
|
||||
{
|
||||
return OperateAsync(id, dto, AlertStatusEnum.Handled, setAck: true, setHandle: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭告警(处理完成后归档)
|
||||
/// </summary>
|
||||
public Task<Result<bool>> CloseAsync(long id, AlertOperateDto dto)
|
||||
{
|
||||
return OperateAsync(id, dto, AlertStatusEnum.Closed, setAck: false, setHandle: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 忽略告警(误报等场景,记录忽略原因)
|
||||
/// </summary>
|
||||
public Task<Result<bool>> IgnoreAsync(long id, AlertOperateDto dto)
|
||||
{
|
||||
return OperateAsync(id, dto, AlertStatusEnum.Ignored, setAck: false, setHandle: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 转工单(一期仅变更状态并记录 WorkOrderId,工单模块二期对接)
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> ToWorkOrderAsync(long id, AlertOperateDto dto)
|
||||
{
|
||||
var result = await OperateAsync(id, dto, AlertStatusEnum.ToWorkOrder, setAck: false, setHandle: false);
|
||||
if (result.IsSuccess && dto?.WorkOrderId > 0)
|
||||
{
|
||||
await SqlSugarContext.DbContext.Updateable<AlertMessageEntity>()
|
||||
.SetColumns(x => x.WorkOrderId == dto.WorkOrderId)
|
||||
.Where(x => x.Id == id)
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 闭环操作统一实现:校验终态 → 更新状态与操作人/时间
|
||||
/// </summary>
|
||||
private async Task<Result<bool>> OperateAsync(long id, AlertOperateDto dto, AlertStatusEnum target, bool setAck, bool setHandle)
|
||||
{
|
||||
if (id <= 0)
|
||||
{
|
||||
return Result<bool>.Error("告警 Id 无效");
|
||||
}
|
||||
try
|
||||
{
|
||||
var entity = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.Id == id && x.IsDel == 0)
|
||||
.FirstAsync();
|
||||
if (entity == null)
|
||||
{
|
||||
return Result<bool>.Error("告警不存在或已被删除");
|
||||
}
|
||||
// 已关闭/已忽略为终态,不允许再流转
|
||||
if (entity.AlertStatus is AlertStatusEnum.Closed or AlertStatusEnum.Ignored)
|
||||
{
|
||||
return Result<bool>.Error($"告警已处于【{StatusLabel(entity.AlertStatus)}】终态,无法再变更");
|
||||
}
|
||||
|
||||
var now = DateTime.Now;
|
||||
string? op = dto?.Operator;
|
||||
string? remark = dto?.Remark;
|
||||
|
||||
var updater = SqlSugarContext.DbContext.Updateable<AlertMessageEntity>()
|
||||
.SetColumns(x => x.AlertStatus == target);
|
||||
if (setAck && entity.AckTime == null)
|
||||
{
|
||||
updater = updater.SetColumns(x => new AlertMessageEntity { AckBy = op, AckTime = now });
|
||||
}
|
||||
if (setHandle)
|
||||
{
|
||||
updater = updater.SetColumns(x => new AlertMessageEntity { HandleBy = op, HandleTime = now, HandleRemark = remark });
|
||||
}
|
||||
var rows = await updater.Where(x => x.Id == id).ExecuteCommandAsync();
|
||||
return Result<bool>.Success(rows > 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("告警操作失败", ex);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 统计分析
|
||||
/// <summary>
|
||||
/// 告警概览(列表页顶部卡片)
|
||||
/// </summary>
|
||||
public async Task<Result<AlertOverviewDto>> GetOverviewAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var db = SqlSugarContext.DbContext;
|
||||
var overview = new AlertOverviewDto
|
||||
{
|
||||
UnacknowledgedCount = await db.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.AlertStatus == AlertStatusEnum.Unacknowledged).CountAsync(),
|
||||
TodayCount = await db.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= today).CountAsync(),
|
||||
TodayUrgentCount = await db.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= today && x.AlertLevel == AlertLevelEnum.Urgent).CountAsync(),
|
||||
WeekCount = await db.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= today.AddDays(-6)).CountAsync()
|
||||
};
|
||||
return Result<AlertOverviewDto>.Success(overview);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<AlertOverviewDto>.Error("查询告警概览失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 趋势统计(granularity:day/week/month;缺省时间范围为近30天)
|
||||
/// </summary>
|
||||
public async Task<Result<List<AlertTrendItemDto>>> GetTrendAsync(string granularity, DateTime? startDate, DateTime? endDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (start, end) = NormalizeRange(startDate, endDate, 30);
|
||||
// 告警经合并窗口去重后数据量可控,取必要列内存聚合(跨库兼容,避免各数据库日期函数差异)
|
||||
var rows = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end)
|
||||
.Select(x => new { x.FirstAlertTime, x.AlertLevel, x.AlertStatus })
|
||||
.ToListAsync();
|
||||
|
||||
var grouped = rows.GroupBy(x => BucketOf(x.FirstAlertTime, granularity))
|
||||
.OrderBy(g => g.Key, StringComparer.Ordinal)
|
||||
.Select(g => new AlertTrendItemDto
|
||||
{
|
||||
Bucket = g.Key,
|
||||
Total = g.Count(),
|
||||
InfoCount = g.Count(x => x.AlertLevel == AlertLevelEnum.Info),
|
||||
WarningCount = g.Count(x => x.AlertLevel == AlertLevelEnum.Warning),
|
||||
UrgentCount = g.Count(x => x.AlertLevel == AlertLevelEnum.Urgent),
|
||||
UnhandledCount = g.Count(x => x.AlertStatus is AlertStatusEnum.Unacknowledged or AlertStatusEnum.Acknowledged)
|
||||
}).ToList();
|
||||
return Result<List<AlertTrendItemDto>>.Success(grouped);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<List<AlertTrendItemDto>>.Error("查询告警趋势失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分布统计(dimension:device/type/level/source)
|
||||
/// </summary>
|
||||
public async Task<Result<List<AlertDistributionItemDto>>> GetDistributionAsync(string dimension, DateTime? startDate, DateTime? endDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (start, end) = NormalizeRange(startDate, endDate, 30);
|
||||
var rows = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end)
|
||||
.Select(x => new { x.DeviceCode, x.DeviceName, x.AlertType, x.AlertLevel, x.Source })
|
||||
.ToListAsync();
|
||||
|
||||
List<AlertDistributionItemDto> result = (dimension ?? "level").ToLower() switch
|
||||
{
|
||||
"device" => rows.GroupBy(x => string.IsNullOrWhiteSpace(x.DeviceName) ? x.DeviceCode ?? "未知设备" : $"{x.DeviceName}({x.DeviceCode})")
|
||||
.Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count).Take(20).ToList(),
|
||||
"type" => rows.GroupBy(x => TypeLabel(x.AlertType))
|
||||
.Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count).ToList(),
|
||||
"source" => rows.GroupBy(x => SourceLabel(x.Source))
|
||||
.Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count).ToList(),
|
||||
_ => rows.GroupBy(x => AlertLevelMap.ToLabel(x.AlertLevel))
|
||||
.Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count).ToList()
|
||||
};
|
||||
return Result<List<AlertDistributionItemDto>>.Success(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<List<AlertDistributionItemDto>>.Error("查询告警分布失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 高频告警 TOP 排名(按设备聚合,次数含合并累计 TriggerCount)
|
||||
/// </summary>
|
||||
public async Task<Result<List<AlertTopItemDto>>> GetTopAsync(int topN, DateTime? startDate, DateTime? endDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (topN <= 0 || topN > 100) topN = 10;
|
||||
var (start, end) = NormalizeRange(startDate, endDate, 30);
|
||||
var rows = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end)
|
||||
.Select(x => new { x.DeviceCode, x.DeviceName, x.TriggerCount, x.LastAlertTime })
|
||||
.ToListAsync();
|
||||
|
||||
var top = rows.GroupBy(x => x.DeviceCode ?? "未知设备")
|
||||
.Select(g => new AlertTopItemDto
|
||||
{
|
||||
DeviceCode = g.Key,
|
||||
DeviceName = g.First().DeviceName,
|
||||
Count = g.Sum(x => x.TriggerCount),
|
||||
LastAlertTime = g.Max(x => x.LastAlertTime)
|
||||
})
|
||||
.OrderByDescending(x => x.Count)
|
||||
.Take(topN)
|
||||
.ToList();
|
||||
return Result<List<AlertTopItemDto>>.Success(top);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<List<AlertTopItemDto>>.Error("查询高频告警失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理及时率统计(平均确认/处理时长;及时率 = 30分钟内确认的占比)
|
||||
/// </summary>
|
||||
public async Task<Result<AlertTimelinessDto>> GetTimelinessAsync(DateTime? startDate, DateTime? endDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (start, end) = NormalizeRange(startDate, endDate, 30);
|
||||
var rows = await SqlSugarContext.DbContext.Queryable<AlertMessageEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end)
|
||||
.Select(x => new { x.FirstAlertTime, x.AckTime, x.HandleTime, x.AlertStatus })
|
||||
.ToListAsync();
|
||||
|
||||
const int timelyMinutes = 30;
|
||||
var acked = rows.Where(x => x.AckTime != null).ToList();
|
||||
var handled = rows.Where(x => x.HandleTime != null &&
|
||||
x.AlertStatus is AlertStatusEnum.Handled or AlertStatusEnum.Closed).ToList();
|
||||
|
||||
var result = new AlertTimelinessDto
|
||||
{
|
||||
Total = rows.Count,
|
||||
AckCount = acked.Count,
|
||||
HandledCount = handled.Count,
|
||||
UnhandledCount = rows.Count(x => x.AlertStatus is AlertStatusEnum.Unacknowledged or AlertStatusEnum.Acknowledged),
|
||||
AvgAckMinutes = acked.Count > 0
|
||||
? Math.Round(acked.Average(x => (x.AckTime!.Value - x.FirstAlertTime).TotalMinutes), 1)
|
||||
: null,
|
||||
AvgHandleMinutes = handled.Count > 0
|
||||
? Math.Round(handled.Average(x => (x.HandleTime!.Value - x.FirstAlertTime).TotalMinutes), 1)
|
||||
: null,
|
||||
TimelyRate = rows.Count > 0
|
||||
? Math.Round(100.0 * acked.Count(x => (x.AckTime!.Value - x.FirstAlertTime).TotalMinutes <= timelyMinutes) / rows.Count, 1)
|
||||
: 0
|
||||
};
|
||||
return Result<AlertTimelinessDto>.Success(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<AlertTimelinessDto>.Error("查询及时率统计失败", ex);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 私有工具
|
||||
/// <summary>
|
||||
/// 时间范围缺省处理:未传时默认近 defaultDays 天
|
||||
/// </summary>
|
||||
private static (DateTime start, DateTime end) NormalizeRange(DateTime? startDate, DateTime? endDate, int defaultDays)
|
||||
{
|
||||
var end = endDate ?? DateTime.Now;
|
||||
var start = startDate ?? end.AddDays(-defaultDays);
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 时间分桶(day:yyyy-MM-dd / week:yyyy-Www / month:yyyy-MM)
|
||||
/// </summary>
|
||||
private static string BucketOf(DateTime time, string granularity)
|
||||
{
|
||||
return (granularity ?? "day").ToLower() switch
|
||||
{
|
||||
"week" => $"{ISOWeek.GetYear(time)}-W{ISOWeek.GetWeekOfYear(time):D2}",
|
||||
"month" => time.ToString("yyyy-MM"),
|
||||
_ => time.ToString("yyyy-MM-dd")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 告警状态中文标签
|
||||
/// </summary>
|
||||
public static string StatusLabel(AlertStatusEnum status) => status switch
|
||||
{
|
||||
AlertStatusEnum.Unacknowledged => "未确认",
|
||||
AlertStatusEnum.Acknowledged => "已确认",
|
||||
AlertStatusEnum.Handled => "已处理",
|
||||
AlertStatusEnum.Closed => "已关闭",
|
||||
AlertStatusEnum.Ignored => "已忽略",
|
||||
AlertStatusEnum.ToWorkOrder => "已转工单",
|
||||
_ => "未知"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 告警类型中文标签
|
||||
/// </summary>
|
||||
private static string TypeLabel(AlertTypeEnum type) => type switch
|
||||
{
|
||||
AlertTypeEnum.ThresholdExceed => "阈值超限",
|
||||
AlertTypeEnum.DeviceOffline => "设备离线",
|
||||
AlertTypeEnum.CommFault => "通讯故障",
|
||||
AlertTypeEnum.PatrolAbnormal => "巡检异常",
|
||||
AlertTypeEnum.Custom => "自定义",
|
||||
_ => "其它"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 告警来源中文标签
|
||||
/// </summary>
|
||||
private static string SourceLabel(AlertSourceEnum source) => source switch
|
||||
{
|
||||
AlertSourceEnum.RuleEngine => "规则引擎",
|
||||
AlertSourceEnum.AutoPatrol => "自动巡检",
|
||||
AlertSourceEnum.Dashboard => "数据看板",
|
||||
AlertSourceEnum.Collection => "数据采集",
|
||||
AlertSourceEnum.Manual => "手动上报",
|
||||
_ => "其它"
|
||||
};
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Service.Implement
|
||||
{
|
||||
/// <summary>
|
||||
/// 告警通知事件(AlertId:告警消息Id;IsNewAlert:是否新告警,false 表示合并触发的重复告警)
|
||||
/// </summary>
|
||||
/// <param name="AlertId">告警消息 Id</param>
|
||||
/// <param name="IsNewAlert">是否新告警</param>
|
||||
public record AlertNotifyEvent(long AlertId, bool IsNewAlert);
|
||||
|
||||
/// <summary>
|
||||
/// 告警通知内存总线(生产者:AlertCenterService 上报告警后入队;消费者:AlertNotifyWorker 后台推送)
|
||||
/// 采用静态单例 Channel,与项目 SqlSugarContext.DbContext 静态单例风格一致;
|
||||
/// 进程重启丢失队列可接受(告警已落库,页面仍可查询处理)
|
||||
/// </summary>
|
||||
public static class AlertNotifyBus
|
||||
{
|
||||
private static readonly Channel<AlertNotifyEvent> _channel =
|
||||
Channel.CreateUnbounded<AlertNotifyEvent>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 队列读取端(供 AlertNotifyWorker 消费)
|
||||
/// </summary>
|
||||
public static ChannelReader<AlertNotifyEvent> Reader => _channel.Reader;
|
||||
|
||||
/// <summary>
|
||||
/// 发布告警通知事件(非阻塞,队列满不会发生——无界队列)
|
||||
/// </summary>
|
||||
public static void Publish(long alertId, bool isNewAlert)
|
||||
{
|
||||
_channel.Writer.TryWrite(new AlertNotifyEvent(alertId, isNewAlert));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// 告警通知配置服务实现(渠道/通知规则/推送日志/值班排班/跨网发件箱)
|
||||
/// </summary>
|
||||
public class AlertNotifyService : IAlertNotifyService
|
||||
{
|
||||
private readonly IEnumerable<IAlertNotifier> _notifiers;
|
||||
|
||||
public AlertNotifyService(IEnumerable<IAlertNotifier> notifiers)
|
||||
{
|
||||
_notifiers = notifiers;
|
||||
}
|
||||
|
||||
#region 通知渠道
|
||||
/// <summary>
|
||||
/// 渠道列表(Secret 脱敏,只返回 HasSecret 标记)
|
||||
/// </summary>
|
||||
public async Task<Result<List<AlertNotifyChannelDto>>> GetChannelsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = await SqlSugarContext.DbContext.Queryable<AlertNotifyChannelEntity>()
|
||||
.Where(x => x.IsDel == 0)
|
||||
.OrderBy(x => x.CreateTime, OrderByType.Desc)
|
||||
.ToListAsync();
|
||||
return Result<List<AlertNotifyChannelDto>>.Success(list.ToDtoList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<List<AlertNotifyChannelDto>>.Error("查询通知渠道失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增渠道
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> AddChannelAsync(AlertNotifyChannelDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto?.Name) || string.IsNullOrWhiteSpace(dto?.WebhookUrl))
|
||||
{
|
||||
return Result<bool>.Error("渠道名称与 Webhook 地址不能为空");
|
||||
}
|
||||
try
|
||||
{
|
||||
var entity = dto.ToEntity();
|
||||
entity.Id = 0; // 防止前端误传 Id,新增一律由雪花生成
|
||||
entity.CreateTime = DateTime.Now;
|
||||
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("新增通知渠道失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改渠道(Secret 传空表示保持原值不覆盖——出口已脱敏,前端编辑时不回传明文)
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> UpdateChannelAsync(AlertNotifyChannelDto dto)
|
||||
{
|
||||
var entity = dto?.ToEntity();
|
||||
if (entity == null || entity.Id <= 0)
|
||||
{
|
||||
return Result<bool>.Error("渠道 Id 无效");
|
||||
}
|
||||
try
|
||||
{
|
||||
var updater = SqlSugarContext.DbContext.Updateable<AlertNotifyChannelEntity>()
|
||||
.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<bool>.Success(rows > 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("修改通知渠道失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除渠道(软删除)
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteChannelAsync(long id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await SqlSugarContext.DbContext.Updateable<AlertNotifyChannelEntity>()
|
||||
.SetColumns(x => x.IsDel == 1)
|
||||
.Where(x => x.Id == id)
|
||||
.ExecuteCommandAsync();
|
||||
return Result<bool>.Success(rows > 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("删除通知渠道失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 渠道测试推送(发送测试消息验证 Webhook 配置,结果写入推送日志)
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> TestSendAsync(long channelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var channel = await SqlSugarContext.DbContext.Queryable<AlertNotifyChannelEntity>()
|
||||
.Where(x => x.Id == channelId && x.IsDel == 0)
|
||||
.FirstAsync();
|
||||
if (channel == null)
|
||||
{
|
||||
return Result<bool>.Error("渠道不存在或已被删除");
|
||||
}
|
||||
|
||||
var notifier = _notifiers.FirstOrDefault(n => n.ChannelType == channel.ChannelType);
|
||||
if (notifier == null)
|
||||
{
|
||||
return Result<bool>.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<string>());
|
||||
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<bool>.Success(true)
|
||||
: Result<bool>.Error($"测试推送失败: {sendResult.ErrorMsg}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("测试推送失败", ex);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 通知规则
|
||||
/// <summary>
|
||||
/// 规则列表
|
||||
/// </summary>
|
||||
public async Task<Result<List<AlertNotifyRuleDto>>> GetRulesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = await SqlSugarContext.DbContext.Queryable<AlertNotifyRuleEntity>()
|
||||
.Where(x => x.IsDel == 0)
|
||||
.OrderBy(x => x.AlertLevel, OrderByType.Desc)
|
||||
.OrderBy(x => x.CreateTime, OrderByType.Desc)
|
||||
.ToListAsync();
|
||||
return Result<List<AlertNotifyRuleDto>>.Success(list.ToDtoList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<List<AlertNotifyRuleDto>>.Error("查询通知规则失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增规则
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> AddRuleAsync(AlertNotifyRuleDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto?.RuleName))
|
||||
{
|
||||
return Result<bool>.Error("规则名称不能为空");
|
||||
}
|
||||
if (dto.ChannelId == null || !long.TryParse(dto.ChannelId, out var channelId) || channelId <= 0)
|
||||
{
|
||||
return Result<bool>.Error("请选择有效的通知渠道");
|
||||
}
|
||||
try
|
||||
{
|
||||
var entity = dto.ToEntity();
|
||||
entity.Id = 0;
|
||||
entity.CreateTime = DateTime.Now;
|
||||
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("新增通知规则失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改规则
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> UpdateRuleAsync(AlertNotifyRuleDto dto)
|
||||
{
|
||||
var entity = dto?.ToEntity();
|
||||
if (entity == null || entity.Id <= 0)
|
||||
{
|
||||
return Result<bool>.Error("规则 Id 无效");
|
||||
}
|
||||
try
|
||||
{
|
||||
var rows = await SqlSugarContext.DbContext.Updateable(entity)
|
||||
.IgnoreColumns(x => new { x.CreateTime, x.IsDel })
|
||||
.ExecuteCommandAsync();
|
||||
return Result<bool>.Success(rows > 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("修改通知规则失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除规则(软删除)
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteRuleAsync(long id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await SqlSugarContext.DbContext.Updateable<AlertNotifyRuleEntity>()
|
||||
.SetColumns(x => x.IsDel == 1)
|
||||
.Where(x => x.Id == id)
|
||||
.ExecuteCommandAsync();
|
||||
return Result<bool>.Success(rows > 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("删除通知规则失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用/停用规则
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> SetRuleEnabledAsync(long id, bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await SqlSugarContext.DbContext.Updateable<AlertNotifyRuleEntity>()
|
||||
.SetColumns(x => x.IsEnabled == enabled)
|
||||
.Where(x => x.Id == id)
|
||||
.ExecuteCommandAsync();
|
||||
return Result<bool>.Success(rows > 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("更新规则启用状态失败", ex);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 推送日志
|
||||
/// <summary>
|
||||
/// 推送日志分页(可按成功状态/渠道筛选)
|
||||
/// </summary>
|
||||
public async Task<Result<List<AlertNotifyLogDto>>> GetLogsPagedAsync(int pageIndex, int pageSize, RefAsync<int> total,
|
||||
bool? success, long channelId = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = await SqlSugarContext.DbContext.Queryable<AlertNotifyLogEntity>()
|
||||
.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<List<AlertNotifyLogDto>>.Success(list.ToDtoList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<List<AlertNotifyLogDto>>.Error("查询推送日志失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入推送日志(内部公共方法,Worker 也复用)
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
// 日志写入失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Webhook 地址脱敏(保留主机名与路径结构,隐藏末段令牌与查询参数)
|
||||
/// 飞书令牌在路径末段(/hook/{token})、钉钉在 access_token、企微在 key,均需遮蔽,避免日志泄露密钥
|
||||
/// </summary>
|
||||
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 值班排班
|
||||
/// <summary>
|
||||
/// 值班排班列表(按日期范围,缺省查本月)
|
||||
/// </summary>
|
||||
public async Task<Result<List<DutyRosterDto>>> 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<DutyRosterEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.DutyDate >= start.Date && x.DutyDate <= end.Date)
|
||||
.OrderBy(x => x.DutyDate, OrderByType.Asc)
|
||||
.ToListAsync();
|
||||
return Result<List<DutyRosterDto>>.Success(list.ToDtoList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<List<DutyRosterDto>>.Error("查询值班排班失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增排班
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> AddDutyAsync(DutyRosterDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto?.PersonName))
|
||||
{
|
||||
return Result<bool>.Error("值班人不能为空");
|
||||
}
|
||||
try
|
||||
{
|
||||
var entity = dto.ToEntity();
|
||||
entity.Id = 0;
|
||||
entity.CreateTime = DateTime.Now;
|
||||
await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("新增值班排班失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改排班
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> UpdateDutyAsync(DutyRosterDto dto)
|
||||
{
|
||||
var entity = dto?.ToEntity();
|
||||
if (entity == null || entity.Id <= 0)
|
||||
{
|
||||
return Result<bool>.Error("排班 Id 无效");
|
||||
}
|
||||
try
|
||||
{
|
||||
var rows = await SqlSugarContext.DbContext.Updateable(entity)
|
||||
.IgnoreColumns(x => new { x.CreateTime, x.IsDel })
|
||||
.ExecuteCommandAsync();
|
||||
return Result<bool>.Success(rows > 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("修改值班排班失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除排班(软删除)
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteDutyAsync(long id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await SqlSugarContext.DbContext.Updateable<DutyRosterEntity>()
|
||||
.SetColumns(x => x.IsDel == 1)
|
||||
.Where(x => x.Id == id)
|
||||
.ExecuteCommandAsync();
|
||||
return Result<bool>.Success(rows > 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("删除值班排班失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询指定日期的值班人
|
||||
/// </summary>
|
||||
public async Task<DutyRosterEntity?> GetDutyByDateAsync(DateTime date)
|
||||
{
|
||||
return await SqlSugarContext.DbContext.Queryable<DutyRosterEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.DutyDate == date.Date)
|
||||
.FirstAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 跨网发件箱(AlertBridge 对接)
|
||||
/// <summary>
|
||||
/// 写入推送日志(供 AlertNotifyWorker 直连推送后复用)
|
||||
/// </summary>
|
||||
public async Task WriteLogAsync(long alertId, long channelId, string content, bool success, string? errorMsg, int retryCount)
|
||||
{
|
||||
var channel = await SqlSugarContext.DbContext.Queryable<AlertNotifyChannelEntity>()
|
||||
.Where(x => x.Id == channelId)
|
||||
.FirstAsync();
|
||||
if (channel == null) return;
|
||||
await InsertLogAsync(alertId, channel, content,
|
||||
success ? NotifyResult.Ok() : NotifyResult.Fail(errorMsg ?? "未知错误"), retryCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入跨网发件箱(供 AlertNotifyWorker 的 Outbox 模式复用)
|
||||
/// </summary>
|
||||
public async Task EnqueueOutboxAsync(long alertId, long channelId, AlertNotifyMessage message, List<string> atMobiles)
|
||||
{
|
||||
var channel = await SqlSugarContext.DbContext.Queryable<AlertNotifyChannelEntity>()
|
||||
.Where(x => x.Id == channelId && x.IsDel == 0)
|
||||
.FirstAsync();
|
||||
if (channel == null) return;
|
||||
await InsertOutboxAsync(alertId, channel, message, atMobiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拉取待推送发件箱(先查后标记;单跳板机实例场景足够,多实例并发时可改用数据库锁)
|
||||
/// </summary>
|
||||
public async Task<Result<List<AlertOutboxItemDto>>> PullOutboxAsync(int batch)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (batch <= 0 || batch > 100) batch = 20;
|
||||
var pending = await SqlSugarContext.DbContext.Queryable<AlertOutboxEntity>()
|
||||
.Where(x => x.IsDel == 0 && x.Status == OutboxStatusEnum.Pending)
|
||||
.OrderBy(x => x.CreateTime, OrderByType.Asc)
|
||||
.Take(batch)
|
||||
.ToListAsync();
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return Result<List<AlertOutboxItemDto>>.Success(new List<AlertOutboxItemDto>());
|
||||
}
|
||||
|
||||
// 标记已拉取(仅更新仍为 Pending 的记录,防止重复拉取)
|
||||
var ids = pending.Select(x => x.Id).ToList();
|
||||
await SqlSugarContext.DbContext.Updateable<AlertOutboxEntity>()
|
||||
.SetColumns(x => new AlertOutboxEntity { Status = OutboxStatusEnum.Pulled, PullTime = DateTime.Now })
|
||||
.Where(x => ids.Contains(x.Id) && x.Status == OutboxStatusEnum.Pending)
|
||||
.ExecuteCommandAsync();
|
||||
|
||||
return Result<List<AlertOutboxItemDto>>.Success(pending.ToDtoList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<List<AlertOutboxItemDto>>.Error("拉取发件箱失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 回写发件箱推送结果(失败累加重试次数;结果同步写推送日志便于页面追溯)
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> ReportOutboxResultAsync(long id, OutboxResultDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var item = await SqlSugarContext.DbContext.Queryable<AlertOutboxEntity>()
|
||||
.Where(x => x.Id == id && x.IsDel == 0)
|
||||
.FirstAsync();
|
||||
if (item == null)
|
||||
{
|
||||
return Result<bool>.Error("发件箱记录不存在");
|
||||
}
|
||||
|
||||
var status = dto?.Success == true ? OutboxStatusEnum.Success : OutboxStatusEnum.Failed;
|
||||
// 表达式树不支持 ?. 空传播,先提取局部变量
|
||||
bool success = dto?.Success == true;
|
||||
string? resultMsg = dto?.ResultMsg;
|
||||
await SqlSugarContext.DbContext.Updateable<AlertOutboxEntity>()
|
||||
.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<AlertNotifyChannelEntity>()
|
||||
.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<bool>.Success(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<bool>.Error("回写发件箱结果失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入发件箱(Worker 的 Outbox 模式与渠道测试推送共用;Payload 自包含全部推送要素)
|
||||
/// </summary>
|
||||
internal async Task InsertOutboxAsync(long alertId, AlertNotifyChannelEntity channel, AlertNotifyMessage message, List<string> 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Common.Notify;
|
||||
using Model.Entity.Inspection;
|
||||
using Service.Interface;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Service.Implement
|
||||
{
|
||||
/// <summary>
|
||||
/// 钉钉群机器人通知发送器(自定义机器人 Webhook,支持加签校验)
|
||||
/// 报文:msgtype=markdown,at.atMobiles 支持 @手机号;
|
||||
/// 加签:URL 追加 timestamp(毫秒)+sign(算法见 ImWebhookSigner.DingTalkSign)
|
||||
/// </summary>
|
||||
public class DingTalkNotifier : IAlertNotifier
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<DingTalkNotifier> _logger;
|
||||
|
||||
public DingTalkNotifier(HttpClient httpClient, ILogger<DingTalkNotifier> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public NotifyChannelTypeEnum ChannelType => NotifyChannelTypeEnum.DingTalk;
|
||||
|
||||
public async Task<NotifyResult> SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
string url = webhookUrl;
|
||||
// 加签:URL 追加 timestamp 与 sign 参数
|
||||
if (!string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
long timestampMs = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
||||
string sign = ImWebhookSigner.DingTalkSign(timestampMs, secret);
|
||||
url += $"{(url.Contains('?') ? "&" : "?")}timestamp={timestampMs}&sign={sign}";
|
||||
}
|
||||
|
||||
// markdown 正文中 @ 手机号需同时出现在 atMobiles 与正文里才会真正提醒
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine($"### {message.Title}");
|
||||
text.AppendLine(message.Content);
|
||||
foreach (var mobile in message.AtMobiles)
|
||||
{
|
||||
text.Append($"@{mobile} ");
|
||||
}
|
||||
|
||||
var body = new Dictionary<string, object>
|
||||
{
|
||||
["msgtype"] = "markdown",
|
||||
["markdown"] = new Dictionary<string, object>
|
||||
{
|
||||
["title"] = message.Title,
|
||||
["text"] = text.ToString()
|
||||
},
|
||||
["at"] = new Dictionary<string, object>
|
||||
{
|
||||
["atMobiles"] = message.AtMobiles,
|
||||
["isAtAll"] = false
|
||||
}
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(body);
|
||||
_logger.LogInformation("钉钉推送 atMobiles=[{At}]", string.Join(",", message.AtMobiles));
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await _httpClient.PostAsync(url, content, ct);
|
||||
string respText = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return NotifyResult.Fail($"HTTP {(int)response.StatusCode}: {Truncate(respText)}");
|
||||
}
|
||||
|
||||
// 钉钉返回 {"errcode":0,"errmsg":"ok"}
|
||||
using var doc = JsonDocument.Parse(respText);
|
||||
bool ok = doc.RootElement.TryGetProperty("errcode", out var errcode) && errcode.GetInt32() == 0;
|
||||
return ok ? NotifyResult.Ok() : NotifyResult.Fail(Truncate(respText));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return NotifyResult.Fail($"钉钉推送异常: {NotifyError.Describe(ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max = 200) =>
|
||||
string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Common.Notify;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Service.Interface;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Service.Implement
|
||||
{
|
||||
/// <summary>
|
||||
/// 飞书通讯录服务实现:手机号 → open_id
|
||||
/// 依赖飞书自建应用凭证(FeishuConfig);tenant_access_token 与 open_id 结果均做进程级内存缓存,
|
||||
/// 避免每条告警都调用飞书 API(token 有效期约 7200s,open_id 基本不变)。
|
||||
/// </summary>
|
||||
public class FeishuContactService : IFeishuContactService
|
||||
{
|
||||
private const string TokenUrl = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
|
||||
private const string BatchGetIdUrl = "https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<FeishuContactService> _logger;
|
||||
|
||||
// tenant_access_token 进程级缓存(提前 5 分钟刷新,避免边界失效)
|
||||
private static string? _token;
|
||||
private static DateTime _tokenExpireAt = DateTime.MinValue;
|
||||
private static readonly SemaphoreSlim _tokenLock = new(1, 1);
|
||||
|
||||
// 手机号 → open_id 结果缓存(换不到的也缓存 null 避免反复调用;重启进程可重置)
|
||||
private static readonly ConcurrentDictionary<string, string?> _openIdCache = new();
|
||||
|
||||
public FeishuContactService(IHttpClientFactory httpClientFactory, ILogger<FeishuContactService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用手机号换取飞书 open_id;未配置凭证、查无此人或无权限时返回 null
|
||||
/// </summary>
|
||||
public async Task<string?> GetOpenIdByMobileAsync(string mobile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mobile)) return null;
|
||||
mobile = mobile.Trim();
|
||||
|
||||
if (_openIdCache.TryGetValue(mobile, out var cached)) return cached;
|
||||
|
||||
if (!FeishuConfig.IsConfigured)
|
||||
{
|
||||
_logger.LogWarning("飞书应用凭证未配置(AppId/AppSecret),无法把手机号 {Mobile} 转 open_id;请在 appsettings.json 的 Feishu 节配置", mobile);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var token = await GetTenantAccessTokenAsync();
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
|
||||
var http = _httpClientFactory.CreateClient();
|
||||
string body = JsonSerializer.Serialize(new { mobiles = new[] { mobile } });
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, BatchGetIdUrl);
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
using var resp = await http.SendAsync(req);
|
||||
string respText = await resp.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(respText);
|
||||
var root = doc.RootElement;
|
||||
int code = root.TryGetProperty("code", out var c) ? c.GetInt32() : -1;
|
||||
if (code != 0)
|
||||
{
|
||||
string msg = root.TryGetProperty("msg", out var m) ? m.GetString() ?? "" : "";
|
||||
_logger.LogWarning("飞书手机号换 open_id 失败 mobile={Mobile} code={Code} msg={Msg}(多为权限未开通或通讯录范围不含该成员)", mobile, code, msg);
|
||||
_openIdCache[mobile] = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
string? openId = null;
|
||||
if (root.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("user_list", out var list) &&
|
||||
list.ValueKind == JsonValueKind.Array && list.GetArrayLength() > 0 &&
|
||||
list[0].TryGetProperty("user_id", out var uid))
|
||||
{
|
||||
openId = uid.GetString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(openId))
|
||||
_logger.LogWarning("飞书手机号 {Mobile} 未查到 open_id(该手机号可能不在应用通讯录范围内)", mobile);
|
||||
|
||||
_openIdCache[mobile] = openId;
|
||||
return openId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("飞书手机号换 open_id 异常 mobile={Mobile}: {Err}", mobile, NotifyError.Describe(ex));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 tenant_access_token(进程级缓存,提前 5 分钟刷新)
|
||||
/// </summary>
|
||||
private async Task<string?> GetTenantAccessTokenAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_token) && DateTime.Now < _tokenExpireAt) return _token;
|
||||
await _tokenLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_token) && DateTime.Now < _tokenExpireAt) return _token;
|
||||
|
||||
var http = _httpClientFactory.CreateClient();
|
||||
string body = JsonSerializer.Serialize(new { app_id = FeishuConfig.AppId, app_secret = FeishuConfig.AppSecret });
|
||||
using var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
using var resp = await http.PostAsync(TokenUrl, content);
|
||||
string respText = await resp.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(respText);
|
||||
var root = doc.RootElement;
|
||||
int code = root.TryGetProperty("code", out var c) ? c.GetInt32() : -1;
|
||||
if (code != 0)
|
||||
{
|
||||
string msg = root.TryGetProperty("msg", out var m) ? m.GetString() ?? "" : "";
|
||||
_logger.LogError("飞书获取 tenant_access_token 失败 code={Code} msg={Msg}(检查 AppId/AppSecret)", code, msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
_token = root.TryGetProperty("tenant_access_token", out var t) ? t.GetString() : null;
|
||||
int expire = root.TryGetProperty("expire", out var e) ? e.GetInt32() : 7200;
|
||||
_tokenExpireAt = DateTime.Now.AddSeconds(Math.Max(60, expire - 300));
|
||||
return _token;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("飞书获取 tenant_access_token 异常: {Err}", NotifyError.Describe(ex));
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tokenLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Common.Notify;
|
||||
using Model.Entity.Inspection;
|
||||
using Service.Interface;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Service.Implement
|
||||
{
|
||||
/// <summary>
|
||||
/// 飞书群机器人通知发送器(自定义机器人 Webhook,支持加签校验)
|
||||
/// 报文:msg_type=post 富文本,用 at 标签(user_id=open_id)真正 @ 人;加签时附 timestamp(秒)+sign(算法见 ImWebhookSigner.FeishuSign)
|
||||
/// </summary>
|
||||
public class FeishuNotifier : IAlertNotifier
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<FeishuNotifier> _logger;
|
||||
|
||||
public FeishuNotifier(HttpClient httpClient, ILogger<FeishuNotifier> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public NotifyChannelTypeEnum ChannelType => NotifyChannelTypeEnum.Feishu;
|
||||
|
||||
public async Task<NotifyResult> SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 飞书 text 消息不支持结构化 @,必须用 post 富文本 + at 标签才能真正 @ 到人。
|
||||
// at 标签的 user_id 为飞书 open_id(ou_ 开头);如需 @ 所有人,接收人填 "all"。
|
||||
var contentLines = new List<List<Dictionary<string, object>>>();
|
||||
foreach (var raw in (message.Content ?? string.Empty).Split('\n'))
|
||||
{
|
||||
var line = raw.TrimEnd('\r');
|
||||
if (line.Length == 0) continue;
|
||||
contentLines.Add(new List<Dictionary<string, object>>
|
||||
{
|
||||
new Dictionary<string, object> { ["tag"] = "text", ["text"] = line }
|
||||
});
|
||||
}
|
||||
if (message.AtMobiles.Count > 0)
|
||||
{
|
||||
var atLine = new List<Dictionary<string, object>>
|
||||
{
|
||||
new Dictionary<string, object> { ["tag"] = "text", ["text"] = "关注人: " }
|
||||
};
|
||||
foreach (var openId in message.AtMobiles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(openId)) continue;
|
||||
atLine.Add(new Dictionary<string, object> { ["tag"] = "at", ["user_id"] = openId.Trim() });
|
||||
}
|
||||
contentLines.Add(atLine);
|
||||
}
|
||||
|
||||
var body = new Dictionary<string, object>
|
||||
{
|
||||
["msg_type"] = "post",
|
||||
["content"] = new Dictionary<string, object>
|
||||
{
|
||||
["post"] = new Dictionary<string, object>
|
||||
{
|
||||
["zh_cn"] = new Dictionary<string, object>
|
||||
{
|
||||
["title"] = message.Title,
|
||||
["content"] = contentLines
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 加签:timestamp(秒) + sign
|
||||
if (!string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
long timestampSec = DateTimeOffset.Now.ToUnixTimeSeconds();
|
||||
body["timestamp"] = timestampSec.ToString();
|
||||
body["sign"] = ImWebhookSigner.FeishuSign(timestampSec, secret);
|
||||
}
|
||||
|
||||
string json = JsonSerializer.Serialize(body);
|
||||
_logger.LogInformation("飞书推送(post富文本) @open_id=[{At}]", string.Join(",", message.AtMobiles));
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await _httpClient.PostAsync(webhookUrl, content, ct);
|
||||
string respText = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return NotifyResult.Fail($"HTTP {(int)response.StatusCode}: {Truncate(respText)}");
|
||||
}
|
||||
|
||||
// 飞书返回 {"code":0,...}(旧版为 {"StatusCode":0,...})
|
||||
using var doc = JsonDocument.Parse(respText);
|
||||
var root = doc.RootElement;
|
||||
bool ok = (root.TryGetProperty("code", out var code) && code.GetInt32() == 0)
|
||||
|| (root.TryGetProperty("StatusCode", out var sc) && sc.GetInt32() == 0);
|
||||
return ok ? NotifyResult.Ok() : NotifyResult.Fail(Truncate(respText));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return NotifyResult.Fail($"飞书推送异常: {NotifyError.Describe(ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max = 200) =>
|
||||
string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Model.Entity.Inspection;
|
||||
using Service.Interface;
|
||||
using Common.Notify;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Service.Implement
|
||||
{
|
||||
/// <summary>
|
||||
/// 企业微信群机器人通知发送器(群机器人 Webhook 无加签机制)
|
||||
/// 报文:msgtype=text(text 类型才支持 mentioned_mobile_list @手机号)
|
||||
/// </summary>
|
||||
public class WeComNotifier : IAlertNotifier
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WeComNotifier> _logger;
|
||||
|
||||
public WeComNotifier(HttpClient httpClient, ILogger<WeComNotifier> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public NotifyChannelTypeEnum ChannelType => NotifyChannelTypeEnum.WeCom;
|
||||
|
||||
public async Task<NotifyResult> SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine(message.Title);
|
||||
text.AppendLine(message.Content);
|
||||
|
||||
var body = new Dictionary<string, object>
|
||||
{
|
||||
["msgtype"] = "text",
|
||||
["text"] = new Dictionary<string, object>
|
||||
{
|
||||
["content"] = text.ToString(),
|
||||
["mentioned_mobile_list"] = message.AtMobiles
|
||||
}
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(body);
|
||||
_logger.LogInformation("企微推送 mentioned_mobile_list=[{At}]", string.Join(",", message.AtMobiles));
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await _httpClient.PostAsync(webhookUrl, content, ct);
|
||||
string respText = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return NotifyResult.Fail($"HTTP {(int)response.StatusCode}: {Truncate(respText)}");
|
||||
}
|
||||
|
||||
// 企微返回 {"errcode":0,"errmsg":"ok"}
|
||||
using var doc = JsonDocument.Parse(respText);
|
||||
bool ok = doc.RootElement.TryGetProperty("errcode", out var errcode) && errcode.GetInt32() == 0;
|
||||
return ok ? NotifyResult.Ok() : NotifyResult.Fail(Truncate(respText));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return NotifyResult.Fail($"企微推送异常: {NotifyError.Describe(ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max = 200) =>
|
||||
string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user