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;
}
// S8-ROLE-TENANT-SCOPE-1:显式透传租户作为安全边界。
// 注意用 input.TenantId 而非 layer.TenantId —— 全局层(tenant_id=0)也必须
// 按**触发异常的租户**解析收件人,否则全局层会解析不到任何角色。
userIds = await _roleResolver.ResolveUserIdsAsync(input.TenantId, 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,
// 通知日志的 factory 列写兼容值 0:归属只由 tenant 决定。
factoryId: S8ConfigScope.GlobalFactoryId,
exceptionId: input.ExceptionId,
userIds: userIds,
notification: notification,
channels: channels);
}
catch (Exception ex)
{
// PushAdapter 自身已对内部错误做了捕获;这里再兜一层保证主流程不被打断。
_logger.LogWarning(ex, "S8LayerDispatch: push throw (layer id={LayerId})", layer.Id);
}
}
}
///
/// 新收件人模型的投递。复用与 LEGACY 完全相同的 与
/// PushAdapter —— 两条路径只在"收件人怎么来"上不同,通知内容与投递方式必须一致,
/// 否则同一条异常会因为配置来源不同而呈现出不同的消息。
///
private async Task DispatchToUsersAsync(DispatchByLayerInput input, S8RecipientResolution resolution)
{
if (resolution.UserIds.Count == 0)
{
// 0 收件人必须可观测:配了却发不出去,与"没配置"是两回事。
// 绝不静默返回让调用方以为发送成功 —— 那正是本模块反复出现的失败形态。
_logger.LogWarning(
"S8RecipientDispatch: resolved 0 recipients (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})",
input.TenantId, input.EventCode, input.ExceptionId,
string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
return;
}
_logger.LogInformation(
"S8RecipientDispatch: source={Source} recipients={Count} (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})",
resolution.Source, resolution.UserIds.Count, input.TenantId, input.EventCode, input.ExceptionId,
string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
// ① 站内信:这是本部署里**唯一会为用户留下一条可见消息**的通道。
//
// 已注册的 pusher 只有 DingTalk / WorkWeixin / SignalR / Sms —— 没有站内信。
// 旧的分层派发因此从来只推 SignalR(用户不在线就等于没发过),
// 而认领 / 转派的站内信是靠一段写死收件人的代码单独发的。
// 新模型把两者合并:所有事件都先落站内信,再推实时通道。
// 少了这一步,本次重构会静默丢掉认领 / 转派原本有的站内信(本地实测已发生)。
try
{
await _noticeService.PublishToUsersAsync(
input.Title,
BuildNoticeContent(input),
resolution.UserIds.ToArray(),
0,
"S8异常监控");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "S8RecipientDispatch: notice publish throw (event={Event})", input.EventCode);
}
// ② 实时通道:在线用户即时可见;不在线也不影响①已经留下的站内信。
try
{
await _pushAdapter.PushAsync(
tenantId: input.TenantId,
factoryId: S8ConfigScope.GlobalFactoryId,
exceptionId: input.ExceptionId,
userIds: resolution.UserIds,
notification: BuildNotification(input),
channels: DefaultChannels);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "S8RecipientDispatch: push throw (event={Event})", input.EventCode);
}
}
/// 站内信正文:内容 + 一个能点回异常详情的链接。
private static string BuildNoticeContent(DispatchByLayerInput input)
{
var jump = string.IsNullOrWhiteSpace(input.JumpUrl) ? null : $"/#{input.JumpUrl}";
return $"{input.ExceptionNo} {input.Content}
"
+ (jump == null ? string.Empty : $"查看异常详情
");
}
///
/// 新模型的投递渠道。只列真实可用的两个 —— 站内信与 SignalR,
/// 与旧分层表里实际在用的 notify_channel='log,SignalR' 同源。
/// 不为了 UI 看起来完整而虚构短信 / 邮件之类当前根本没有实现的渠道。
///
private static readonly List DefaultChannels = new() { "log", "SignalR" };
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();
}
}