using Admin.NET.Plugin.AiDOP.Entity.S8;
using Admin.NET.Plugin.AiDOP.Infrastructure;
using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
using Admin.NET.Plugin.ApprovalFlow.Service;
using Microsoft.Extensions.Logging;
namespace Admin.NET.Plugin.AiDOP.Service.S8;
///
/// S8 通知分层路由解析器(S8-NOTIFY-LAYER-RESOLVE-1)
///
/// 职责链:
/// (tenantId, factoryId, sceneCode, severity)
/// → 命中 ado_s8_notification_layer 行(多行 OK)
/// → 解析 target_role_ids(dual-format)
/// → 解析 notify_channel(IgnoreCase, ',' / ';' 分隔)
/// → S8RoleResolver → userIds
/// → S8NotificationPushAdapter.PushAsync
/// → 由 PushAdapter 落 AdoS8NotificationLog(**每个「已注册且被选中」的渠道**一条)
///
/// 不接入 watch / scheduler / task 主链路;不写 ApprovalFlowNotifyLog;
/// 不修改任何 schema;任何分支异常仅 LogWarning,不向上抛。
///
/// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:平台默认回落粒度 = **(scene, severity, level) 逐层**。
/// 一次 OR 查询取回「平台默认 (0,0) ∪ 本工厂」的同 scene+severity 行,再交
/// 逐 level 取工厂优先。
/// 修正前是整键 fallback(精确租户查到任意一行即完全不读 (0,0)),会导致工厂补一层就丢掉平台其余层级。
/// merge 与配置页 ListAsync 共用同一实现,禁止各自复制。
///
public class S8NotificationLayerResolver : ITransient
{
private readonly SqlSugarRepository _layerRep;
private readonly S8RoleResolver _roleResolver;
private readonly S8NotificationPushAdapter _pushAdapter;
private readonly ILogger _logger;
public S8NotificationLayerResolver(
SqlSugarRepository layerRep,
S8RoleResolver roleResolver,
S8NotificationPushAdapter pushAdapter,
ILogger logger)
{
_layerRep = layerRep;
_roleResolver = roleResolver;
_pushAdapter = pushAdapter;
_logger = logger;
}
public class DispatchByLayerInput
{
public long TenantId { get; set; }
public long FactoryId { get; set; }
public long? ExceptionId { get; set; }
public string? ExceptionNo { get; set; }
public string SceneCode { get; set; } = string.Empty;
public string Severity { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public string? Status { get; set; }
public string? SourceRuleCode { get; set; }
public string? JumpUrl { get; set; }
///
/// S8-NOTIFY-WIRE-RECOVERED-1:true 表示恢复事件,BuildNotification 会在 Context 中追加
/// "recovered"="true"。默认 false,保持 CREATED 路径载荷向后兼容。
///
public bool Recovered { get; set; }
// ============================================================
// S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计字段(可选;CREATED 路径透传,RECOVERED 路径置空)。
// 由 S8WatchSchedulerService.TryDispatchLayerNotificationAsync 调 S8ImpactMetricsService 计算后传入。
// ============================================================
public int? RepeatCount30d { get; set; }
public decimal? CumulativeLossHours30d { get; set; }
public string? SuggestedAttentionLevel { get; set; }
public string? SuggestedAttentionLabel { get; set; }
public string? ImpactReason { get; set; }
// ============================================================
// S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警字段(可选;仅 CloseAsync 命中 closedAt > slaDeadline 时传入)。
// 语义与 TimeoutFlag 运行时口径分离:TimeoutFlag 仅看未关闭超时;OverdueClosed 是已关闭后的闭环及时性提醒。
// ============================================================
public bool? OverdueClosed { get; set; }
public DateTime? ClosedAt { get; set; }
public DateTime? SlaDeadlineRef { get; set; }
public decimal? OverdueCloseHours { get; set; }
}
///
/// 调用方:watch/scheduler/task 在拿到 sceneCode + severity 后调用本方法(本轮不接入主链路)。
///
public async Task DispatchByLayerAsync(DispatchByLayerInput input)
{
if (input == null)
{
_logger.LogWarning("S8LayerDispatch: input null");
return;
}
if (string.IsNullOrWhiteSpace(input.SceneCode) || string.IsNullOrWhiteSpace(input.Severity))
{
_logger.LogInformation("S8LayerDispatch skip: empty sceneCode or severity (exceptionId={ExceptionId})", input.ExceptionId);
return;
}
// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:覆盖粒度由「整个 (scene, severity) 键」收敛为**逐 level 覆盖**。
//
// 原实现是两次往返 + 整键 fallback(精确租户查到任意一行 → 完全不读 (0,0))。
// 那意味着工厂只要为某一层建一行,平台默认在该 scene+severity 下的其余层级会整体消失——
// 工厂本意是「补一层」,实际是「换掉整组」,且该副作用在配置页上不可见。
//
// 现在一次 OR 查询同时取回平台默认与本工厂行,再由 S8NotificationLayerMerge 逐 level 取舍。
// merge 与配置页 ListAsync 共用同一函数,杜绝「页面显示一种、运行时派发另一种」。
List layers;
var severity = S8SeverityCode.Normalize(input.Severity);
try
{
var candidates = await _layerRep.AsQueryable()
.Where(x => x.SceneCode == input.SceneCode && x.Severity == severity)
.Where(x => (x.TenantId == input.TenantId && x.FactoryId == input.FactoryId)
|| (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId))
.ToListAsync();
layers = S8NotificationLayerMerge.Effective(candidates);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "S8LayerDispatch: query AdoS8NotificationLayer failed (scene={Scene}, sev={Sev})", input.SceneCode, input.Severity);
return;
}
if (layers.Count > 0)
_logger.LogInformation(
"S8LayerDispatch: {Total} effective layer rows across {Levels} level(s) for scene={Scene} sev={Sev}; {G} from platform default (tenant=0/factory=0)",
layers.Count,
layers.Select(x => S8NotificationLayerMerge.NormalizeLevel(x.LevelCode)).Distinct().Count(),
input.SceneCode, severity,
layers.Count(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId)));
if (layers.Count == 0)
{
_logger.LogInformation("S8LayerDispatch: no layer matched (tenant={Tenant}, factory={Factory}, scene={Scene}, sev={Sev})",
input.TenantId, input.FactoryId, input.SceneCode, input.Severity);
return;
}
var notification = BuildNotification(input);
foreach (var layer in layers)
{
List userIds;
try
{
var tokens = S8RoleResolver.SplitTokens(layer.TargetRoleIds);
if (tokens.Count == 0)
{
_logger.LogWarning("S8LayerDispatch: layer id={LayerId} target_role_ids empty, skip row", layer.Id);
continue;
}
userIds = await _roleResolver.ResolveUserIdsAsync(tokens);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "S8LayerDispatch: resolve roles failed for layer id={LayerId}", layer.Id);
continue;
}
if (userIds.Count == 0)
{
_logger.LogWarning("S8LayerDispatch: layer id={LayerId} resolved 0 users from target_role_ids='{Roles}'",
layer.Id, layer.TargetRoleIds);
continue;
}
var channels = ParseChannels(layer.NotifyChannel);
try
{
await _pushAdapter.PushAsync(
tenantId: input.TenantId,
factoryId: input.FactoryId,
exceptionId: input.ExceptionId,
userIds: userIds,
notification: notification,
channels: channels);
}
catch (Exception ex)
{
// PushAdapter 自身已对内部错误做了捕获;这里再兜一层保证主流程不被打断。
_logger.LogWarning(ex, "S8LayerDispatch: push throw (layer id={LayerId})", layer.Id);
}
}
}
private static FlowNotification BuildNotification(DispatchByLayerInput input)
{
// 注意:FlowNotificationTypeEnum 不含 S8_EXCEPTION 值;BizType 字段承载 "S8_EXCEPTION" 语义。
// InstanceId 仅作为载荷字段(PushAdapter 不写 ApprovalFlowNotifyLog,不会脏写该列)。
var ctx = new Dictionary
{
["exceptionId"] = input.ExceptionId?.ToString(),
["exceptionNo"] = input.ExceptionNo,
["sceneCode"] = input.SceneCode,
["severity"] = input.Severity,
["status"] = input.Status,
["sourceRuleCode"] = input.SourceRuleCode,
["jumpUrl"] = input.JumpUrl,
};
if (input.Recovered) ctx["recovered"] = "true";
// S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计 5 字段,仅 CREATED 路径携带,RECOVERED 路径不传入。
if (input.RepeatCount30d.HasValue)
ctx["repeatCount30d"] = input.RepeatCount30d.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (input.CumulativeLossHours30d.HasValue)
ctx["cumulativeLossHours30d"] = input.CumulativeLossHours30d.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLevel))
ctx["suggestedAttentionLevel"] = input.SuggestedAttentionLevel;
if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLabel))
ctx["suggestedAttentionLabel"] = input.SuggestedAttentionLabel;
if (!string.IsNullOrWhiteSpace(input.ImpactReason))
ctx["impactReason"] = input.ImpactReason;
// S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警 4 字段,仅 CloseAsync 命中 closedAt > slaDeadline 时携带。
if (input.OverdueClosed == true)
ctx["overdueClosed"] = "true";
if (input.ClosedAt.HasValue)
ctx["closedAt"] = input.ClosedAt.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
if (input.SlaDeadlineRef.HasValue)
ctx["slaDeadline"] = input.SlaDeadlineRef.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
if (input.OverdueCloseHours.HasValue)
ctx["overdueCloseHours"] = input.OverdueCloseHours.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
return new FlowNotification
{
Type = FlowNotificationTypeEnum.NewTask,
BizType = "S8_EXCEPTION",
InstanceId = input.ExceptionId ?? 0,
Title = input.Title ?? string.Empty,
Content = input.Content ?? string.Empty,
Context = ctx,
};
}
public static List ParseChannels(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return new List();
return csv.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
}