using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
using Admin.NET.Plugin.AiDOP.SmartOps;
namespace Admin.NET.Plugin.AiDOP.Service.S8;
///
/// 九宫格 S8 的当前异常与模块灯态。异常权威是 ado_s8_exception,监控权威是 ado_s8_watch_rule。
///
public sealed class S8HomeAlertQueryService : ITransient
{
public const int RecentLimit = 6;
public const int MinFreshWindowSeconds = 600;
/// 列表、汇总、趋势共用的业务维度过滤。只匹配异常权威表上的可追溯字段。
public const string TextFilterSql = """
AND (@product IS NULL OR IFNULL(title,'') LIKE CONCAT('%', @product, '%')
OR IFNULL(description,'') LIKE CONCAT('%', @product, '%')
OR IFNULL(related_object_code,'') LIKE CONCAT('%', @product, '%')
OR IFNULL(source_object_id,'') LIKE CONCAT('%', @product, '%')
OR IFNULL(source_payload,'') LIKE CONCAT('%', @product, '%')
OR IFNULL(exception_code,'') LIKE CONCAT('%', @product, '%'))
AND (@orderNo IS NULL OR IFNULL(title,'') LIKE CONCAT('%', @orderNo, '%')
OR IFNULL(description,'') LIKE CONCAT('%', @orderNo, '%')
OR IFNULL(related_object_code,'') LIKE CONCAT('%', @orderNo, '%')
OR IFNULL(source_object_id,'') LIKE CONCAT('%', @orderNo, '%')
OR IFNULL(source_payload,'') LIKE CONCAT('%', @orderNo, '%')
OR IFNULL(exception_code,'') LIKE CONCAT('%', @orderNo, '%'))
AND (@productionLine IS NULL OR IFNULL(title,'') LIKE CONCAT('%', @productionLine, '%')
OR IFNULL(description,'') LIKE CONCAT('%', @productionLine, '%')
OR IFNULL(related_object_code,'') LIKE CONCAT('%', @productionLine, '%')
OR IFNULL(source_object_id,'') LIKE CONCAT('%', @productionLine, '%')
OR IFNULL(source_payload,'') LIKE CONCAT('%', @productionLine, '%')
OR IFNULL(exception_code,'') LIKE CONCAT('%', @productionLine, '%'))
""";
private const string ActiveStatusSql = "status NOT IN ('CLOSED','REJECTED','RECOVERED')";
private readonly ISqlSugarClient _db;
public S8HomeAlertQueryService(ISqlSugarClient db) => _db = db;
public async Task> GetRecentAsync(long tenantId, SmartOpsDashboardFilter filter)
{
var sql = $"""
SELECT DATE_FORMAT(COALESCE(last_detected_at, created_at), '%H:%i') AS Time,
IFNULL(NULLIF(stage_code,''), IFNULL(NULLIF(module_code,''), 'S8')) AS Module,
IFNULL(NULLIF(title,''), IFNULL(NULLIF(description,''), '异常告警')) AS Message,
severity AS Severity
FROM ado_s8_exception
WHERE tenant_id=@tenantId AND is_deleted=0 AND {ActiveStatusSql}
AND (@ds IS NULL OR created_at >= @ds)
AND (@de IS NULL OR created_at < DATE_ADD(@de, INTERVAL 1 DAY))
{TextFilterSql}
ORDER BY COALESCE(last_detected_at, created_at) DESC, id DESC
LIMIT {RecentLimit}
""";
var args = FilterArgs(tenantId, filter);
args["ds"] = filter.DateStart;
args["de"] = filter.DateEnd;
var rows = await _db.Ado.SqlQueryAsync(sql, args);
return rows.Select(MapAlert).ToList();
}
public async Task GetCreatedSummaryAsync(
long tenantId, SmartOpsDashboardFilter filter, DateTime windowStart, DateTime windowEnd, bool isToday)
{
var sql = $"""
SELECT COUNT(*) AS Total,
IFNULL(SUM(CASE WHEN UPPER(IFNULL(severity,'')) IN ('SERIOUS','HIGH','CRITICAL') THEN 1 ELSE 0 END), 0) AS Critical
FROM ado_s8_exception
WHERE tenant_id=@tenantId AND is_deleted=0
AND created_at >= @ws AND created_at < @we
{TextFilterSql}
""";
var args = FilterArgs(tenantId, filter);
args["ws"] = windowStart;
args["we"] = windowEnd;
var row = (await _db.Ado.SqlQueryAsync(sql, args)).FirstOrDefault();
return new S8HomeAlertSummary(windowStart, windowEnd, isToday, row?.Total ?? 0, row?.Critical ?? 0);
}
public async Task> GetCreatedTrendAsync(
long tenantId, SmartOpsDashboardFilter filter, DateTime start, DateTime end)
{
var sql = $"""
SELECT DATE(created_at) AS BizDate, COUNT(*) AS Cnt
FROM ado_s8_exception
WHERE tenant_id=@tenantId AND is_deleted=0
AND created_at >= @ds AND created_at < DATE_ADD(@de, INTERVAL 1 DAY)
{TextFilterSql}
GROUP BY DATE(created_at)
ORDER BY BizDate
""";
var args = FilterArgs(tenantId, filter);
args["ds"] = start;
args["de"] = end;
var rows = await _db.Ado.SqlQueryAsync(sql, args);
var byDate = rows.ToDictionary(x => x.BizDate.Date, x => x.Cnt);
var points = new List();
for (var day = start.Date; day <= end.Date; day = day.AddDays(1))
points.Add(new S8HomeTrendPoint(day, byDate.GetValueOrDefault(day)));
return points;
}
public async Task GetModuleLampsAsync(long tenantId, DateTime now)
{
const string exceptionSql = """
SELECT stage_code AS StageCode, severity AS Severity
FROM ado_s8_exception
WHERE tenant_id=@tenantId AND is_deleted=0
AND status NOT IN ('CLOSED','REJECTED','RECOVERED')
AND stage_code IN ('S1','S2','S3','S4','S5','S6','S7')
""";
const string ruleSql = """
SELECT stage_code AS StageCode, enabled AS Enabled, poll_interval_seconds AS PollIntervalSeconds,
last_status AS LastStatus, last_run_at AS LastRunAt, paused_until AS PausedUntil
FROM ado_s8_watch_rule
WHERE tenant_id=@tenantId
AND stage_code IN ('S1','S2','S3','S4','S5','S6','S7')
""";
var exceptions = await _db.Ado.SqlQueryAsync(exceptionSql, new { tenantId });
var ruleRows = await _db.Ado.SqlQueryAsync(ruleSql, new { tenantId });
var rules = ruleRows.Select(x => new S8WatchRuleSignal(
x.StageCode ?? "",
x.Enabled,
x.PollIntervalSeconds,
x.LastStatus,
x.LastRunAt,
x.PausedUntil)).ToList();
var items = S8ModuleCode.All.Select(code =>
{
var stageRows = exceptions.Where(x => string.Equals(x.StageCode, code, StringComparison.Ordinal)).ToList();
var serious = stageRows.Count(x => S8SeverityCode.IsSerious(x.Severity));
var follow = stageRows.Count - serious;
var stageRules = rules.Where(x => string.Equals(x.StageCode, code, StringComparison.Ordinal)).ToList();
var decision = S8ModuleLampPolicy.Decide(serious, follow, stageRules, now);
return new S8ModuleLampItem(
code,
S8ModuleCode.Label(code),
decision.BusinessState,
decision.MonitorState,
serious,
follow,
stageRows.Count,
stageRules.Count,
stageRules.Count(x => x.Enabled),
stageRules.Count(x => S8ModuleLampPolicy.IsRunnable(x, now)),
stageRules.Where(x => x.LastRunAt.HasValue).Select(x => x.LastRunAt).Max(),
decision.MonitorReasonCode);
}).ToList();
return new S8ModuleLampPayload(now, items);
}
private static S8HomeAlertItem MapAlert(S8HomeAlertRaw row)
{
var serious = S8SeverityCode.IsSerious(row.Severity);
return new S8HomeAlertItem(
row.Time ?? "",
string.IsNullOrWhiteSpace(row.Module) ? "S8" : row.Module,
string.IsNullOrWhiteSpace(row.Message) ? "异常告警" : row.Message,
serious ? "critical" : "medium",
serious ? "严重" : "关注");
}
private static Dictionary FilterArgs(long tenantId, SmartOpsDashboardFilter filter) => new()
{
["tenantId"] = tenantId,
["product"] = BlankToNull(filter.Product),
["orderNo"] = BlankToNull(filter.OrderNo),
["productionLine"] = BlankToNull(filter.ProductionLine)
};
private static string? BlankToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
}
public static class S8ModuleLampPolicy
{
public static int FreshWindowSeconds(int pollIntervalSeconds) =>
Math.Max(pollIntervalSeconds * 2, S8HomeAlertQueryService.MinFreshWindowSeconds);
public static bool IsRunnable(S8WatchRuleSignal rule, DateTime now) =>
rule.Enabled && (!rule.PausedUntil.HasValue || rule.PausedUntil.Value <= now);
public static S8ModuleLampDecision Decide(
int seriousCount, int followCount, IReadOnlyList rules, DateTime now)
{
var (monitorState, reason) = ResolveMonitor(rules, now);
var business = seriousCount > 0 ? "CRITICAL"
: followCount > 0 ? "WARNING"
: monitorState == "HEALTHY" ? "NORMAL"
: "UNKNOWN";
return new S8ModuleLampDecision(business, monitorState, reason);
}
private static (string State, string Reason) ResolveMonitor(IReadOnlyList rules, DateTime now)
{
if (rules.Count == 0)
return ("UNMONITORED", "NO_RULE");
var runnable = rules.Where(x => IsRunnable(x, now)).ToList();
if (runnable.Count == 0)
return ("PAUSED", rules.Any(x => x.Enabled) ? "RULE_PAUSED" : "RULE_DISABLED");
var reasons = runnable.Select(x => RuleReason(x, now)).Where(x => x != "OK").ToList();
if (reasons.Count == 0)
return ("HEALTHY", "OK");
if (reasons.Contains("RUN_FAILED"))
return ("DEGRADED", "RUN_FAILED");
if (reasons.Contains("NEVER_RUN"))
return ("DEGRADED", "NEVER_RUN");
return ("DEGRADED", "RUN_STALE");
}
private static string RuleReason(S8WatchRuleSignal rule, DateTime now)
{
if (!string.Equals(rule.LastStatus, "SUCCESS", StringComparison.OrdinalIgnoreCase))
return rule.LastRunAt.HasValue ? "RUN_FAILED" : "NEVER_RUN";
if (!rule.LastRunAt.HasValue)
return "NEVER_RUN";
var window = TimeSpan.FromSeconds(FreshWindowSeconds(rule.PollIntervalSeconds));
return now - rule.LastRunAt.Value <= window ? "OK" : "RUN_STALE";
}
}
public sealed record S8WatchRuleSignal(
string StageCode,
bool Enabled,
int PollIntervalSeconds,
string? LastStatus,
DateTime? LastRunAt,
DateTime? PausedUntil);
public sealed record S8ModuleLampDecision(string BusinessState, string MonitorState, string MonitorReasonCode);
public sealed record S8HomeAlertItem(string Time, string Module, string Message, string Level, string LevelLabel);
public sealed record S8HomeAlertSummary(DateTime WindowStart, DateTime WindowEnd, bool IsToday, int Total, int Critical);
public sealed record S8HomeTrendPoint(DateTime Date, int Count);
public sealed record S8ModuleLampItem(
string Code,
string Name,
string BusinessState,
string MonitorState,
int SeriousCount,
int FollowCount,
int OpenCount,
int ConfiguredRuleCount,
int EnabledRuleCount,
int RunnableRuleCount,
DateTime? LatestRunAt,
string MonitorReasonCode);
public sealed record S8ModuleLampPayload(DateTime AsOf, IReadOnlyList Items);
internal sealed class S8HomeAlertRaw
{
public string? Time { get; set; }
public string? Module { get; set; }
public string? Message { get; set; }
public string? Severity { get; set; }
}
internal sealed class S8HomeSummaryRaw
{
public int Total { get; set; }
public int Critical { get; set; }
}
internal sealed class S8HomeTrendRaw
{
public DateTime BizDate { get; set; }
public int Cnt { get; set; }
}
internal sealed class S8StageSeverityRaw
{
public string? StageCode { get; set; }
public string? Severity { get; set; }
}
internal sealed class S8WatchRuleRaw
{
public string? StageCode { get; set; }
public bool Enabled { get; set; }
public int PollIntervalSeconds { get; set; }
public string? LastStatus { get; set; }
public DateTime? LastRunAt { get; set; }
public DateTime? PausedUntil { get; set; }
}