Просмотр исходного кода

feat(s8): 新增告警聚合/问题台账/监控调度 Job 与配置

- Infrastructure: S8AlertAggregationTypes、S8IssueLedgerStates 静态类型与状态约束
- Dto: AdoS8AlertAggregationDtos、AdoS8IssueLedgerDtos
- Service: S8AlertAggregationService、S8IssueLedgerService
- Controller: AdoS8AlertAggregationController、AdoS8IssueLedgerController、AdoS8WatchDebugController
- Job: S8WatchSchedulerJob 监控调度任务
- Config: WatchScheduler.json 调度参数
YY968XX 1 день назад
Родитель
Сommit
a3046337f4

+ 12 - 0
server/Admin.NET.Application/Configuration/WatchScheduler.json

@@ -0,0 +1,12 @@
+{
+  "$schema": "https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json",
+
+  // G-08 自动监控调度配置。只承载"appsettings 锚点"一类配置项(见 G08-02 配置分类表)。
+  // 其余参数分别落在属性固化(IntervalMinutes)、代码常量(TenantId / FactoryId / FailurePauseThreshold)
+  // 与 Admin.NET 作业管理(Enabled)等锚点上,不在本文件承载。
+  "WatchScheduler": {
+    // 调试入口 POST /api/aidop/s8/watch-debug/run-once 启用开关。
+    // 开发 / 测试默认 true;正式环境应覆写为 false。
+    "DebugEndpointEnabled": true
+  }
+}

+ 32 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8AlertAggregationController.cs

@@ -0,0 +1,32 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+/// <summary>
+/// G-05 告警归集最小查询接口。仅一个只读 GET,鉴权走现有中间件。
+/// type 仅接受 stuck / failure / 空;不接受别名、不兼容未知 channel。
+/// </summary>
+[ApiController]
+[Route("api/aidop/s8/alerts/aggregation")]
+[NonUnify]
+public class AdoS8AlertAggregationController : ControllerBase
+{
+    private readonly S8AlertAggregationService _svc;
+
+    public AdoS8AlertAggregationController(S8AlertAggregationService svc) => _svc = svc;
+
+    [HttpGet]
+    public async Task<IActionResult> QueryAsync([FromQuery] AdoS8AlertAggregationQueryDto q)
+    {
+        try
+        {
+            var (total, list) = await _svc.QueryAsync(q);
+            return Ok(new { total, list });
+        }
+        catch (S8BizException ex)
+        {
+            return BadRequest(new { message = ex.Message });
+        }
+    }
+}

+ 32 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8IssueLedgerController.cs

@@ -0,0 +1,32 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+/// <summary>
+/// G-06 最小问题台账只读查询接口。仅一个只读 GET,鉴权走现有中间件。
+/// state 仅接受 discovered / processing / closed / 空;不接受别名、不兼容 hold / suspend / pending / all。
+/// </summary>
+[ApiController]
+[Route("api/aidop/s8/issues/ledger")]
+[NonUnify]
+public class AdoS8IssueLedgerController : ControllerBase
+{
+    private readonly S8IssueLedgerService _svc;
+
+    public AdoS8IssueLedgerController(S8IssueLedgerService svc) => _svc = svc;
+
+    [HttpGet]
+    public async Task<IActionResult> QueryAsync([FromQuery] AdoS8IssueLedgerQueryDto q)
+    {
+        try
+        {
+            var (total, list) = await _svc.QueryAsync(q);
+            return Ok(new { total, list });
+        }
+        catch (S8BizException ex)
+        {
+            return BadRequest(new { message = ex.Message });
+        }
+    }
+}

+ 51 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8WatchDebugController.cs

@@ -0,0 +1,51 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Microsoft.Extensions.Configuration;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+/// <summary>
+/// G-01 最小补验内部入口(非正式调度接入点)。
+/// 仅用于触发 S8 自动监控 MVP 主链一次,供端到端联调与验收取证;
+/// 不对外暴露业务语义,不作为长期 API。
+/// G08-06:移除匿名访问;由 appsettings 的 `WatchScheduler:DebugEndpointEnabled` 控制是否启用,
+/// 未启用时所有 Action 返回 404(语义等价于"未注册")。
+/// </summary>
+[ApiController]
+[Route("api/aidop/s8/watch-debug")]
+[NonUnify]
+public class AdoS8WatchDebugController : ControllerBase
+{
+    private readonly S8WatchSchedulerService _svc;
+    private readonly bool _debugEndpointEnabled;
+
+    public AdoS8WatchDebugController(S8WatchSchedulerService svc, IConfiguration configuration)
+    {
+        _svc = svc;
+        _debugEndpointEnabled = configuration.GetValue("WatchScheduler:DebugEndpointEnabled", false);
+    }
+
+    [HttpPost("run-once")]
+    public async Task<IActionResult> RunOnceAsync(
+        [FromQuery] long tenantId = 1,
+        [FromQuery] long factoryId = 1)
+    {
+        if (!_debugEndpointEnabled) return NotFound();
+
+        var results = await _svc.CreateExceptionsAsync(tenantId, factoryId);
+        return Ok(new
+        {
+            count = results.Count,
+            results = results.Select(r => new
+            {
+                created = r.Created,
+                skipped = r.Skipped,
+                createdExceptionId = r.CreatedExceptionId,
+                reason = r.Reason,
+                errorMessage = r.ErrorMessage,
+                sourceRuleId = r.DedupResult.Hit.SourceRuleId,
+                relatedObjectCode = r.DedupResult.Hit.RelatedObjectCode,
+                matchedExceptionId = r.DedupResult.MatchedExceptionId
+            })
+        });
+    }
+}

+ 46 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8AlertAggregationDtos.cs

@@ -0,0 +1,46 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.S8;
+
+/// <summary>
+/// G-05 告警归集查询入参。仅本轮两条既有 channel 的归集口径。
+/// </summary>
+public class AdoS8AlertAggregationQueryDto
+{
+    public long TenantId { get; set; } = 1;
+    public long FactoryId { get; set; } = 1;
+
+    /// <summary>顶层类别:stuck / failure / 不传=全部(两类合并)。</summary>
+    public string? Type { get; set; }
+
+    /// <summary>关联异常单(可选)。</summary>
+    public long? ExceptionId { get; set; }
+
+    public DateTime? BeginTime { get; set; }
+    public DateTime? EndTime { get; set; }
+
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+}
+
+/// <summary>
+/// G-05 告警归集返回条目。字段严格对齐开发清单 4.2 节。
+/// </summary>
+public class AdoS8AlertAggregationItemDto
+{
+    public long Id { get; set; }
+
+    /// <summary>顶层类别:s8-alert.stuck / s8-alert.failure。</summary>
+    public string Type { get; set; } = string.Empty;
+
+    /// <summary>原始 channel 字符串,保留追溯。</summary>
+    public string Channel { get; set; } = string.Empty;
+
+    public long TenantId { get; set; }
+    public long FactoryId { get; set; }
+
+    public long? ExceptionId { get; set; }
+
+    /// <summary>payload 原样透传;G-05 不解析、不归一、不保证跨 channel 结构一致。</summary>
+    public string? Payload { get; set; }
+
+    public DateTime CreatedAt { get; set; }
+}

+ 52 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8IssueLedgerDtos.cs

@@ -0,0 +1,52 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.S8;
+
+/// <summary>
+/// G-06 最小问题台账查询入参。一异常单一台账条目;告警条目不作为独立登记对象。
+/// State 仅接受 discovered / processing / closed 或留空(全量);
+/// hold / suspend / pending / all 等一律非法。
+/// </summary>
+public class AdoS8IssueLedgerQueryDto
+{
+    public long TenantId { get; set; } = 1;
+    public long FactoryId { get; set; } = 1;
+
+    /// <summary>台账状态短码:discovered / processing / closed / 不传=全部。</summary>
+    public string? State { get; set; }
+
+    /// <summary>按异常单 ID 精确查询(可选)。</summary>
+    public long? ExceptionId { get; set; }
+
+    public DateTime? BeginTime { get; set; }
+    public DateTime? EndTime { get; set; }
+
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+}
+
+/// <summary>
+/// G-06 最小问题台账返回条目。字段严格对齐《G-06 最小问题台账》§四。
+/// 字段名为口径层称呼,不作物理字段契约;台账不新建表、不独立持久化。
+/// </summary>
+public class AdoS8IssueLedgerItemDto
+{
+    /// <summary>台账主键(复用异常单 ID)。</summary>
+    public long Id { get; set; }
+
+    public long TenantId { get; set; }
+    public long FactoryId { get; set; }
+
+    /// <summary>发现来源(AUTO_WATCH / MANUAL / OTHER 等;原值透传)。</summary>
+    public string SourceType { get; set; } = string.Empty;
+
+    /// <summary>台账状态短码:discovered / processing / closed。由主链 Status 映射得出。</summary>
+    public string State { get; set; } = string.Empty;
+
+    /// <summary>原始主链状态,保留追溯。</summary>
+    public string RawStatus { get; set; } = string.Empty;
+
+    /// <summary>登记时间(异常单 created_at)。</summary>
+    public DateTime CreatedAt { get; set; }
+
+    /// <summary>闭环时间(仅 CLOSED 触发点;其他均为空)。</summary>
+    public DateTime? ClosedAt { get; set; }
+}

+ 56 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8AlertAggregationTypes.cs

@@ -0,0 +1,56 @@
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>
+/// G-05 告警归集顶层类别与成员 channel 的静态映射。
+/// 口径来源《G-05 开发清单:告警归集口径整理》4.1 节;
+/// 不落库、不可配置、不支持运行期追加;新增类别必须改代码。
+/// 本轮仅纳入两类既有 channel:s8-active-flow-stuck / s8-invoke-handler-failed。
+/// </summary>
+public static class S8AlertAggregationTypes
+{
+    /// <summary>顶层类别:状态滞留型(周期扫描发现)。</summary>
+    public const string Stuck = "s8-alert.stuck";
+
+    /// <summary>顶层类别:执行失败型(事件即时触发)。</summary>
+    public const string Failure = "s8-alert.failure";
+
+    /// <summary>channel → 顶层类别。</summary>
+    private static readonly IReadOnlyDictionary<string, string> ChannelToType =
+        new Dictionary<string, string>(StringComparer.Ordinal)
+        {
+            [S8ActiveFlowWatchChannel] = Stuck,
+            [S8FlowHandlerFailureChannel] = Failure
+        };
+
+    /// <summary>顶层类别 → channel 集合。</summary>
+    private static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> TypeToChannels =
+        new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
+        {
+            [Stuck] = new[] { S8ActiveFlowWatchChannel },
+            [Failure] = new[] { S8FlowHandlerFailureChannel }
+        };
+
+    /// <summary>由 channel 解析顶层类别;未归类返回 null。</summary>
+    public static string? TryGetType(string? channel)
+    {
+        if (string.IsNullOrWhiteSpace(channel)) return null;
+        return ChannelToType.TryGetValue(channel, out var type) ? type : null;
+    }
+
+    /// <summary>由顶层类别获取成员 channel 集合;未知类别返回空集合。</summary>
+    public static IReadOnlyList<string> GetChannels(string? type)
+    {
+        if (string.IsNullOrWhiteSpace(type)) return Array.Empty<string>();
+        return TypeToChannels.TryGetValue(type, out var channels) ? channels : Array.Empty<string>();
+    }
+
+    /// <summary>G-05 纳入范围内的全部 channel 集合(便于 G05-02 查询层复用)。</summary>
+    public static IReadOnlyList<string> AllChannels { get; } =
+        new[] { S8ActiveFlowWatchChannel, S8FlowHandlerFailureChannel };
+
+    // channel 字符串值冻结,与产出侧 S8ActiveFlowWatchService.AlertChannel /
+    // S8FlowHandlerFailureNotifier.AlertChannel 一一对应。此处重复字面值仅为避免
+    // Infrastructure 层反向依赖 Service 层;两处必须保持同步,任一变更属越界改动。
+    private const string S8ActiveFlowWatchChannel = "s8-active-flow-stuck";
+    private const string S8FlowHandlerFailureChannel = "s8-invoke-handler-failed";
+}

+ 58 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8IssueLedgerStates.cs

@@ -0,0 +1,58 @@
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>
+/// G-06 最小问题台账三档状态短码 ↔ 主链状态集合的静态映射。
+/// 口径来源《G-06 最小问题台账》§五;不落库、不可配置、不支持运行期追加;
+/// 短码白名单固定为 discovered / processing / closed,留空视为全量;
+/// hold / suspend / pending / all 等一律视为非法参数。
+/// </summary>
+public static class S8IssueLedgerStates
+{
+    /// <summary>台账状态短码:已登记但尚未被接单。</summary>
+    public const string Discovered = "discovered";
+
+    /// <summary>台账状态短码:已进入处置链路。</summary>
+    public const string Processing = "processing";
+
+    /// <summary>台账状态短码:终局(CLOSED / REJECTED)。</summary>
+    public const string Closed = "closed";
+
+    /// <summary>短码 → 主链状态集合。挂起不存在,hold / suspend / pending 均视为非法参数。</summary>
+    private static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> ShortCodeToStatuses =
+        new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
+        {
+            [Discovered] = new[] { "NEW" },
+            [Processing] = new[] { "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION", "ESCALATED", "RESOLVED" },
+            [Closed] = new[] { "CLOSED", "REJECTED" }
+        };
+
+    /// <summary>由主链状态解析台账短码;未归类返回 null。</summary>
+    public static string? TryGetShortCode(string? status)
+    {
+        if (string.IsNullOrWhiteSpace(status)) return null;
+        return status switch
+        {
+            "NEW" => Discovered,
+            "ASSIGNED" or "IN_PROGRESS" or "PENDING_VERIFICATION" or "ESCALATED" or "RESOLVED" => Processing,
+            "CLOSED" or "REJECTED" => Closed,
+            _ => null
+        };
+    }
+
+    /// <summary>由台账短码获取对应主链状态集合;未知短码返回空集合。</summary>
+    public static IReadOnlyList<string> GetStatuses(string? shortCode)
+    {
+        if (string.IsNullOrWhiteSpace(shortCode)) return Array.Empty<string>();
+        return ShortCodeToStatuses.TryGetValue(shortCode, out var statuses)
+            ? statuses
+            : Array.Empty<string>();
+    }
+
+    /// <summary>三档短码对应的全部主链状态集合(便于查询层复用)。</summary>
+    public static IReadOnlyList<string> AllStatuses { get; } = new[]
+    {
+        "NEW",
+        "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION", "ESCALATED", "RESOLVED",
+        "CLOSED", "REJECTED"
+    };
+}

+ 156 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs

@@ -0,0 +1,156 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// G-08:S8 自动监控主链定时调度作业。
+/// 固定租户 / 工厂上下文下,按周期触发一次 G-01 主链(<see cref="S8WatchSchedulerService.CreateExceptionsAsync"/>)。
+/// 本轮仅完成"正式定时触发接入";不在调度层追加业务逻辑、不改写主链入参语义、不做真实 SQL / 认证链路 / SLA 治理。
+/// 周期由 [Period] 属性固化为默认值(30 分钟);运行期调整走 Admin.NET 作业管理,不走配置中心。
+/// 开关:默认通过 Admin.NET 作业管理的触发器状态控制;本作业构造不自动启动。
+/// </summary>
+[JobDetail("job_s8_watch_scheduler", Description = "S8 自动监控主链定时调度",
+    GroupName = "default", Concurrent = false)]
+[Period(S8WatchSchedulerJob.IntervalMs, TriggerId = "trigger_s8_watch_scheduler", Description = "默认 30 分钟一次")]
+public class S8WatchSchedulerJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ISchedulerFactory _schedulerFactory;
+    private readonly ILogger _logger;
+
+    // G-08 调度周期(毫秒)。属性固化锚点,见 G08-02 配置分类表第 2 项。
+    // 既供 [Period] 使用,也供首次激活日志打印,避免两处数值漂移。
+    public const int IntervalMs = 1800000;
+
+    // G-08 固定租户 / 工厂上下文。与 G-01 验收口径一致。
+    private const long DefaultTenantId = 1;
+    private const long DefaultFactoryId = 1;
+
+    // G-08 作业与触发器 ID。用于自动停调度时精确定位本作业。
+    private const string JobId = "job_s8_watch_scheduler";
+
+    // G08-05 运行期自动停调度:连续失败阈值(代码常量锚点,见 G08-02 配置分类表第 7 项)。
+    private const int FailurePauseThreshold = 2;
+
+    // G08-02 首次作业激活可见性:作业首次激活时打印一次关键运行参数的生效值,后续周期不重复。
+    // 仅覆盖属性固化与代码常量两类;appsettings 与作业管理两类的当前值不在此打印。
+    private static int _firstActivationLogged;
+
+    // G08-05 连续失败计数(仅同进程内有效;跨进程由 Admin.NET 作业管理手工介入,见 G08-03)。
+    private static int _consecutiveFailureCount;
+
+    public S8WatchSchedulerJob(
+        IServiceScopeFactory scopeFactory,
+        ISchedulerFactory schedulerFactory,
+        ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _schedulerFactory = schedulerFactory;
+        _logger = loggerFactory.CreateLogger(nameof(S8WatchSchedulerJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
+        {
+            _logger.LogInformation(
+                "S8WatchSchedulerJob 首次作业激活参数:IntervalMs={IntervalMs} Concurrent=false TenantId={TenantId} FactoryId={FactoryId}",
+                IntervalMs, DefaultTenantId, DefaultFactoryId);
+        }
+
+        var triggeredAt = DateTime.Now;
+        using var scope = _scopeFactory.CreateScope();
+        var scheduler = scope.ServiceProvider.GetRequiredService<S8WatchSchedulerService>();
+
+        try
+        {
+            var results = await scheduler.CreateExceptionsAsync(DefaultTenantId, DefaultFactoryId);
+            var created = results.Count(r => r.Created);
+            var skipped = results.Count(r => r.Skipped);
+            var failed = results.Count(r => !r.Created && !r.Skipped);
+            var firstFailure = results.FirstOrDefault(r => !r.Created && !r.Skipped);
+            _logger.LogInformation(BuildTraceLine(
+                triggeredAt: triggeredAt,
+                result: "success",
+                hits: results.Count,
+                created: created,
+                skipped: skipped,
+                failed: failed,
+                failureSummary: TruncateSummary(firstFailure?.ErrorMessage ?? firstFailure?.Reason)));
+            // 本轮主链未抛异常 → 重置连续失败计数(单条 create_failed 不计入调度失败)。
+            Interlocked.Exchange(ref _consecutiveFailureCount, 0);
+        }
+        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        {
+            _logger.LogInformation(BuildTraceLine(
+                triggeredAt: triggeredAt,
+                result: "cancelled",
+                hits: 0, created: 0, skipped: 0, failed: 0,
+                failureSummary: null));
+            // 取消不计入连续失败。
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, BuildTraceLine(
+                triggeredAt: triggeredAt,
+                result: "error",
+                hits: 0, created: 0, skipped: 0, failed: 0,
+                failureSummary: TruncateSummary(ex.Message)));
+            var count = Interlocked.Increment(ref _consecutiveFailureCount);
+            if (count >= FailurePauseThreshold)
+            {
+                TryAutoPause(count, ex);
+            }
+        }
+    }
+
+    // G08-05 运行期自动停调度:命中 FailurePauseThreshold 后调用框架原生暂停作业能力;
+    // 不自建停调度通道;停调度原因单独留痕一行,便于检索。
+    private void TryAutoPause(int failureCount, Exception lastError)
+    {
+        try
+        {
+            var scheduleResult = _schedulerFactory.TryPauseJob(JobId, out _);
+            _logger.LogWarning(
+                "S8WatchSchedulerJob 自动停调度:FailureCount={FailureCount} Threshold={Threshold} ScheduleResult={ScheduleResult} Reason={Reason}",
+                failureCount, FailurePauseThreshold, scheduleResult, TruncateSummary(lastError.Message));
+        }
+        catch (Exception pauseEx)
+        {
+            // 停调度本身失败不重试、不回退;仅记录,等待人工介入。
+            _logger.LogError(pauseEx,
+                "S8WatchSchedulerJob 自动停调度调用失败:FailureCount={FailureCount}",
+                failureCount);
+        }
+    }
+
+    // G08-04 结构化留痕:单行 JSON 字符串,承载最低 6 字段(触发时间 / 执行结果 / 命中数 / 创建数 / 跳过数 / 失败摘要)。
+    // 不建新表、不上日志库、不上链路追踪;失败摘要仅截断首条错误,不做异常归集。
+    private static string BuildTraceLine(
+        DateTime triggeredAt, string result, int hits, int created, int skipped, int failed, string? failureSummary)
+    {
+        var payload = new
+        {
+            job = "S8WatchSchedulerJob",
+            triggeredAt = triggeredAt.ToString("yyyy-MM-dd HH:mm:ss"),
+            result,
+            hits,
+            created,
+            skipped,
+            failed,
+            failureSummary
+        };
+        return "S8WatchSchedulerJob 留痕 " + JsonSerializer.Serialize(payload);
+    }
+
+    private static string? TruncateSummary(string? raw)
+    {
+        if (string.IsNullOrWhiteSpace(raw)) return null;
+        var oneLine = raw.Replace('\n', ' ').Replace('\r', ' ').Trim();
+        return oneLine.Length <= 200 ? oneLine : oneLine[..200];
+    }
+}

+ 79 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8AlertAggregationService.cs

@@ -0,0 +1,79 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// G-05 只读告警归集服务。
+/// 仅按 G-05 开发清单 4.1~4.3 对 ado_s8_notification_log 做 channel 过滤与顶层类别映射;
+/// 不写库、不落缓存、不做订阅;payload 原样透传。
+/// </summary>
+public class S8AlertAggregationService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8NotificationLog> _rep;
+
+    public S8AlertAggregationService(SqlSugarRepository<AdoS8NotificationLog> rep)
+    {
+        _rep = rep;
+    }
+
+    /// <summary>
+    /// 查询归集条目。参数非法(类别短码越界、分页越界由 PagingGuard 收敛)按异常抛出。
+    /// </summary>
+    public async Task<(int total, List<AdoS8AlertAggregationItemDto> list)> QueryAsync(
+        AdoS8AlertAggregationQueryDto q)
+    {
+        (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize, maxPageSize: 100);
+
+        var channels = ResolveChannels(q.Type);
+
+        var query = _rep.AsQueryable()
+            .Where(x => x.TenantId == q.TenantId && x.FactoryId == q.FactoryId)
+            .Where(x => channels.Contains(x.Channel))
+            .WhereIF(q.ExceptionId.HasValue, x => x.ExceptionId == q.ExceptionId!.Value)
+            .WhereIF(q.BeginTime.HasValue, x => x.CreatedAt >= q.BeginTime!.Value)
+            .WhereIF(q.EndTime.HasValue, x => x.CreatedAt <= q.EndTime!.Value)
+            .OrderBy(x => x.CreatedAt, OrderByType.Desc);
+
+        var total = await query.CountAsync();
+        var rows = await query
+            .Skip((q.Page - 1) * q.PageSize)
+            .Take(q.PageSize)
+            .ToListAsync();
+
+        var list = rows.Select(x => new AdoS8AlertAggregationItemDto
+        {
+            Id = x.Id,
+            Type = S8AlertAggregationTypes.TryGetType(x.Channel) ?? string.Empty,
+            Channel = x.Channel,
+            TenantId = x.TenantId,
+            FactoryId = x.FactoryId,
+            ExceptionId = x.ExceptionId,
+            Payload = x.Payload,
+            CreatedAt = x.CreatedAt
+        }).ToList();
+
+        return (total, list);
+    }
+
+    // 入参短码 → channel 集合:
+    //   "stuck"   → [s8-active-flow-stuck]
+    //   "failure" → [s8-invoke-handler-failed]
+    //   null / 空 → 两类全量
+    //   其他      → 抛 S8BizException(参数非法)
+    private static IReadOnlyList<string> ResolveChannels(string? type)
+    {
+        if (string.IsNullOrWhiteSpace(type))
+            return S8AlertAggregationTypes.AllChannels;
+
+        var normalized = type.Trim().ToLowerInvariant();
+        return normalized switch
+        {
+            "stuck" => S8AlertAggregationTypes.GetChannels(S8AlertAggregationTypes.Stuck),
+            "failure" => S8AlertAggregationTypes.GetChannels(S8AlertAggregationTypes.Failure),
+            _ => throw new S8BizException("type 仅支持 stuck / failure 或留空")
+        };
+    }
+}

+ 82 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8IssueLedgerService.cs

@@ -0,0 +1,82 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// G-06 只读最小问题台账服务。
+/// 仅按《G-06 开发清单 最小问题台账》§4.1~§4.3 对 ado_s8_exception 做只读聚合;
+/// 一异常单一台账条目;不写库、不落缓存、不建独立表;State 仅接受 discovered / processing / closed 或空。
+/// </summary>
+public class S8IssueLedgerService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8Exception> _rep;
+
+    public S8IssueLedgerService(SqlSugarRepository<AdoS8Exception> rep)
+    {
+        _rep = rep;
+    }
+
+    /// <summary>
+    /// 查询台账条目。State 非法、分页越界由 PagingGuard 收敛;State 非法抛 S8BizException。
+    /// </summary>
+    public async Task<(int total, List<AdoS8IssueLedgerItemDto> list)> QueryAsync(
+        AdoS8IssueLedgerQueryDto q)
+    {
+        (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize, maxPageSize: 100);
+
+        var statuses = ResolveStatuses(q.State);
+
+        var query = _rep.AsQueryable()
+            .Where(x => x.TenantId == q.TenantId && x.FactoryId == q.FactoryId)
+            .Where(x => !x.IsDeleted)
+            .Where(x => statuses.Contains(x.Status))
+            .WhereIF(q.ExceptionId.HasValue, x => x.Id == q.ExceptionId!.Value)
+            .WhereIF(q.BeginTime.HasValue, x => x.CreatedAt >= q.BeginTime!.Value)
+            .WhereIF(q.EndTime.HasValue, x => x.CreatedAt <= q.EndTime!.Value)
+            .OrderBy(x => x.CreatedAt, OrderByType.Desc);
+
+        var total = await query.CountAsync();
+        var rows = await query
+            .Skip((q.Page - 1) * q.PageSize)
+            .Take(q.PageSize)
+            .ToListAsync();
+
+        var list = rows.Select(x => new AdoS8IssueLedgerItemDto
+        {
+            Id = x.Id,
+            TenantId = x.TenantId,
+            FactoryId = x.FactoryId,
+            SourceType = x.SourceType,
+            State = S8IssueLedgerStates.TryGetShortCode(x.Status) ?? string.Empty,
+            RawStatus = x.Status,
+            CreatedAt = x.CreatedAt,
+            ClosedAt = x.ClosedAt
+        }).ToList();
+
+        return (total, list);
+    }
+
+    // 入参短码 → 主链状态集合:
+    //   "discovered" → [NEW]
+    //   "processing" → [ASSIGNED, IN_PROGRESS, PENDING_VERIFICATION, ESCALATED, RESOLVED]
+    //   "closed"     → [CLOSED, REJECTED]
+    //   null / 空    → 三档合并(即八个现行主链状态全量)
+    //   其他(含 hold / suspend / pending / all)→ 抛 S8BizException(参数非法)
+    private static IReadOnlyList<string> ResolveStatuses(string? state)
+    {
+        if (string.IsNullOrWhiteSpace(state))
+            return S8IssueLedgerStates.AllStatuses;
+
+        var normalized = state.Trim().ToLowerInvariant();
+        return normalized switch
+        {
+            S8IssueLedgerStates.Discovered => S8IssueLedgerStates.GetStatuses(S8IssueLedgerStates.Discovered),
+            S8IssueLedgerStates.Processing => S8IssueLedgerStates.GetStatuses(S8IssueLedgerStates.Processing),
+            S8IssueLedgerStates.Closed => S8IssueLedgerStates.GetStatuses(S8IssueLedgerStates.Closed),
+            _ => throw new S8BizException("state 仅支持 discovered / processing / closed 或留空")
+        };
+    }
+}