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

feat(s8): 新增监控规则只读预演,解除 Rule 01 集成实验的可测性阻塞

集成实验(真实数据 → Rule → Detection → Exception → 工作流 → Recovery)本轮无法执行,
根因是可测性缺口,不是规则本身有问题。按批次要求先补能力、不污染异常中心。

阻塞点一:没有中间档位
唯一执行入口 run-once 调 CreateExceptionsAsync(tenant, factory),跑该租户**全部启用规则**;
Rule 01 当前 disabled 不会执行,一旦启用则按当前真实数据一次性生成 28 条异常单。
而验证闭环只需要 1 条。没有 dry-run / preview / 单对象模式,等于逼着拿异常中心做试验场。

本次补上这个档位:GET /api/aidop/s8/config/watch-rules/{id}/preview
  · 走**与生产完全相同**的链路 Gateway → Catalog → Provider → Evaluator,
    不另写一份预估 SQL——那样预演与真实运行必然漂移,预演也就失去意义
  · 不写 detection_log / exception / timeline / notification_log / rule_detection_state
  · 不改规则任何列(不动 enabled / next_run_at / last_run_at / lock_*),不发通知
  · 只读既有 exception,回答每条命中「会新建 还是 刷新既有活动异常」
  · enabled=false 的规则同样可预演——这正是它存在的理由:先看清再决定启不启用
  · 用 GET 而非 POST:它是一次查询,用 POST 会让人以为点了就会发生什么

NoHitRows 只给总数、不细分「已完成」与「未到期」:细分需要 preview 自己复刻一遍
evaluator 的判定分支,一旦两边漂移就会给出与真实运行不同的解释。宁可少报,不可假报。

S8WatchRulePreviewContractTests(5 例)把「零副作用」钉成结构约束而非注释与自觉:
依赖里不得出现建单/流转/通知/调度服务、公开方法只允许 PreviewAsync、端点必须是 GET 且带权限、
结果必须能回答会新建多少。有人日后给 preview 加一个"顺便落库"的分支,它就会从
「启用前的安全窗口」变成「另一条没人审过的建单入口」——而且因为名字叫 preview,没人会去审。

阻塞点二(本轮真正的硬障碍,非本次能解)
本地后端连的就是共享 aidopdev(Database.json 生效行 Server=123.60.180.165;Database=aidopdev),
而 Admin.NET.Web.Core/Startup.cs:380 无条件 app.UseAutoVersionUpdate(),
按 ParsedVersion <= csproj(1.0.488) 过滤待执行脚本。实测 1.0.484~487 的 Success 记录数 = 0。
⇒ 只要启动本地后端,冻结的破坏性 1.0.486/1.0.487 会立即作用于共享库
  (删 132 条 Legacy 规则、34 条异常、DROP 两表、DROP 三列)。
本地无独立 mysqld,无法指向克隆库。故本轮不启动服务,实验 3~9 全部未执行。

含义(部署排序):这套架构下不存在「先在本地把新代码跑通、再决定部不部署」——
启动新代码本身就是 schema 部署。集成实验只能安排在部署之后、启用 Rule 01 之前,
而 preview 正是那一步的安全前置。

本轮只读取证(未写共享库,今日新增异常/检测/时间线均为 0)
  · 异常类型 PURCHASE_DELIVERY_ABNORMAL 存在且启用(全局行,S4/SERIOUS/SLA 120min/ROLE_PURCHASER),
    对 Rule 01 作用域有效解析恰 1 条
  · ROLE_PURCHASER 在 Rule 01 租户可解析到 1 名成员;场景 S4 已启用
  · 先例 GEX-012-MP:同租户同类型异常已走完
    CREATE→CLAIM→START_PROGRESS→VERIFY_SUBMITTED→VERIFY_APPROVED→CLOSED 全链并闭环
  · params 里的 dept 245 一度疑似悬挂引用,展开后发现异常表中**所有**部门 id
    相对 SysOrg 都不存在(244/1/10/2/249…),是系统既有状态而非 Rule 01 缺陷,故不改

不改 Rule 01 参数与 enabled、不新增 migration、未触碰冻结的 1.0.486/487、未启动服务、未部署。
YY968XX 1 месяц назад
Родитель
Сommit
3ba4417444

+ 123 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRulePreviewContractTests.cs

@@ -0,0 +1,123 @@
+using System.Reflection;
+using Microsoft.AspNetCore.Mvc;
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-RULE01-INTEGRATION-PREVIEW-1:预演端点的**零副作用**契约。
+///
+/// 预演的全部价值建立在一条保证上:<b>它什么都不写</b>。
+/// 一旦有人给它加一个"顺便把命中落库"的分支,它就从"启用前的安全窗口"
+/// 变成"另一条没人审过的建单入口" —— 而且因为名字叫 preview,没人会去审它。
+/// 因此本文件用结构性断言把这条钉死,而不是依赖注释和自觉。
+///
+/// 不接 DB、不接 DI:全部断言基于类型结构与依赖形态。
+/// </summary>
+public class S8WatchRulePreviewContractTests
+{
+    private static readonly Type Service = typeof(S8WatchRulePreviewService);
+
+    /// <summary>
+    /// 预演服务只允许持有「取数 + 判定 + 读既有异常」所需的依赖。
+    /// 出现任何建单 / 通知 / 流转类服务都意味着它可能产生副作用。
+    /// </summary>
+    [Fact]
+    public void PreviewService_HoldsNoWriteCapableDependency()
+    {
+        var deps = Assert.Single(Service.GetConstructors())
+            .GetParameters().Select(p => p.ParameterType.Name).ToArray();
+
+        foreach (var forbidden in new[]
+                 {
+                     "S8ManualReportService",      // 建单
+                     "S8TaskFlowService",          // 状态流转
+                     "S8NotificationLayerResolver",// 通知
+                     "S8WatchSchedulerService",    // 会写 detection / 推进租约
+                     "S8ExceptionService"          // 异常写入口
+                 })
+            Assert.DoesNotContain(forbidden, deps);
+
+        // 正向:取数网关与两个只读仓储必须在,否则它没法走生产同款链路。
+        Assert.Contains("S8MonitoringDataGateway", deps);
+    }
+
+    /// <summary>
+    /// 预演服务不得出现任何写方法。命名约定在本仓是稳定的
+    /// (Create/Update/Delete/Save/Insert/Write/Execute…),逐个排除比读注释可靠。
+    /// </summary>
+    [Fact]
+    public void PreviewService_ExposesOnlyPreview()
+    {
+        var publicMethods = Service
+            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+            .Select(m => m.Name)
+            .ToArray();
+
+        Assert.Equal(new[] { "PreviewAsync" }, publicMethods);
+
+        foreach (var verb in new[] { "Create", "Update", "Delete", "Save", "Insert", "Write", "Run", "Execute" })
+            Assert.DoesNotContain(publicMethods, n => n.StartsWith(verb, StringComparison.Ordinal));
+    }
+
+    /// <summary>
+    /// 端点必须是 GET。用 POST 会让调用方以为"点一下会发生点什么",
+    /// 也会让它在审计与网关限流里被归入写操作那一类。
+    /// </summary>
+    [Fact]
+    public void PreviewEndpoint_IsHttpGet_AndScoped()
+    {
+        var action = Assert.Single(
+            typeof(AdoS8ConfigWatchRulesController)
+                .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+                .Where(m => m.Name == "PreviewAsync"));
+
+        var get = Assert.Single(action.GetCustomAttributes<HttpGetAttribute>());
+        Assert.Equal("{id:long}/preview", get.Template);
+
+        Assert.Empty(action.GetCustomAttributes<HttpPostAttribute>());
+        Assert.Empty(action.GetCustomAttributes<HttpPutAttribute>());
+        Assert.Empty(action.GetCustomAttributes<HttpDeleteAttribute>());
+
+        // 必须带权限;未登记路由会退回平台的「默认放行」。
+        Assert.NotEmpty(action.GetCustomAttributes()
+            .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal)));
+    }
+
+    /// <summary>
+    /// 预演必须能对 enabled=false 的规则工作 —— 这正是它存在的理由:
+    /// 先看清会命中什么,再决定启不启用。若签名或结果里带上"仅启用规则"的约束,
+    /// 它就退化成了事后观察工具。
+    /// </summary>
+    [Fact]
+    public void PreviewResult_ReportsEnabledState_WithoutRequiringIt()
+    {
+        var enabled = typeof(S8WatchRulePreviewResult).GetProperty("Enabled");
+        Assert.NotNull(enabled);
+        Assert.Equal(typeof(bool), enabled!.PropertyType);
+
+        // 结果必须能回答"会新建多少 / 会命中既有多少"——只给总命中数无法评估污染规模。
+        foreach (var required in new[]
+                 { "CandidateRows", "HitCount", "NoHitRows", "WouldCreateCount", "WouldMatchExistingCount" })
+            Assert.NotNull(typeof(S8WatchRulePreviewResult).GetProperty(required));
+    }
+
+    /// <summary>
+    /// 单条命中必须带 DedupKey 与既有异常 id:
+    /// 没有这两项就无法回答"真跑一次会不会重复建单",而那正是启用前最该确认的事。
+    /// </summary>
+    [Fact]
+    public void PreviewHit_CarriesIdentityAndDedupContext()
+    {
+        foreach (var required in new[]
+                 {
+                     "SourceObjectType", "SourceObjectId", "RelatedObjectCode",
+                     "ExceptionTypeCode", "Severity", "DedupKey", "ExistingActiveExceptionId"
+                 })
+            Assert.NotNull(typeof(S8WatchRulePreviewHit).GetProperty(required));
+
+        Assert.Equal(typeof(long?), typeof(S8WatchRulePreviewHit).GetProperty("ExistingActiveExceptionId")!.PropertyType);
+    }
+}

+ 24 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs

@@ -4,6 +4,7 @@ using System.Security.Claims;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 using Microsoft.Extensions.Logging;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
@@ -125,6 +126,29 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
+    /// <summary>
+    /// S8-RULE01-INTEGRATION-PREVIEW-1:只读预演 —— 「这条规则现在启用会命中什么」。
+    ///
+    /// 在此之前想回答这个问题只能真把规则 enabled=true 然后等调度器跑,
+    /// 对 Rule 01 而言那意味着一次性生成 28 条真实异常单,而验证只需要 1 条。
+    /// 本端点补上这个中间档位:走**与生产完全相同**的 Gateway → Provider → Evaluator 链,
+    /// 但不写 detection_log / exception / rule_detection_state,不改规则任何列,不发通知。
+    ///
+    /// 用 GET 而非 POST 是刻意的:它是一次查询,没有副作用,
+    /// 用 POST 会让调用方以为「点了就会发生什么」。
+    ///
+    /// 权限沿用 ConfigWatchRule(能配规则的人才能预演),作用域由服务端盖章。
+    /// </summary>
+    [HttpGet("{id:long}/preview")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
+    public async Task<IActionResult> PreviewAsync(long id, [FromServices] S8WatchRulePreviewService preview)
+    {
+        try { return Ok(await preview.PreviewAsync(id, await _scope.ResolveAsync())); }
+        catch (S8NotFoundException) { return NotFound(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+        catch (S8RuleEvaluatorException ex) { return BadRequest(new { reason = ex.Reason, message = ex.Message }); }
+    }
+
     /// <summary>
     /// R4 安全更新:仅修改 params_json 与 enabled,绝不接受 expression / rule_code / data_source_id /
     /// scene_code / watch_object_type / rule_type / source_object_type 等敏感字段。

+ 180 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs

@@ -0,0 +1,180 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8-RULE01-INTEGRATION-PREVIEW-1:监控规则的**只读预演**。
+///
+/// <b>解决的问题</b>:在此之前,想知道「一条规则启用后会命中什么」只有一条路 ——
+/// 把它 enabled=true 然后等调度器跑。对 Rule 01 而言那意味着一次性生成 28 条真实异常单,
+/// 而验证只需要 1 条。没有中间档位,等于逼着人拿异常中心当试验场。
+///
+/// <b>本服务走的是与生产完全相同的取数与判定链</b>:
+/// <code>
+///   rule.DatasetCode → S8MonitoringDataGateway → Catalog → Provider → canonical
+///                    → 对应 evaluator(按 rule_type 分派) → hits
+/// </code>
+/// 不是另写一份预估 SQL —— 那样预演结果与真实运行必然漂移,预演也就失去意义。
+///
+/// <b>写入边界(本服务的全部价值都建立在这条上)</b>:
+///   · 不写 ado_s8_detection_log
+///   · 不写 ado_s8_exception / timeline / notification_log
+///   · 不写 ado_s8_rule_detection_state(不推进抗抖计数)
+///   · 不改规则自身的任何列(不动 enabled / next_run_at / last_run_at / lock_*)
+///   · 不发通知
+/// 只读既有 exception 用于回答「这条命中现在是否已有活动异常」。
+///
+/// <b>enabled=false 的规则同样可预演</b>——这正是本服务存在的理由:先看清楚再决定启不启用。
+/// </summary>
+public class S8WatchRulePreviewService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
+    private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
+    private readonly S8MonitoringDataGateway _gateway;
+    private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
+    private readonly S8ShortageRuleEvaluator _shortageEvaluator;
+    private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
+
+    public S8WatchRulePreviewService(
+        SqlSugarRepository<AdoS8WatchRule> ruleRep,
+        SqlSugarRepository<AdoS8Exception> exceptionRep,
+        S8MonitoringDataGateway gateway,
+        S8TimeoutRuleEvaluator timeoutEvaluator,
+        S8ShortageRuleEvaluator shortageEvaluator,
+        S8OutOfRangeRuleEvaluator outOfRangeEvaluator)
+    {
+        _ruleRep = ruleRep;
+        _exceptionRep = exceptionRep;
+        _gateway = gateway;
+        _timeoutEvaluator = timeoutEvaluator;
+        _shortageEvaluator = shortageEvaluator;
+        _outOfRangeEvaluator = outOfRangeEvaluator;
+    }
+
+    /// <summary>
+    /// 预演一条规则。<paramref name="scope"/> 由 Controller 用服务端可信作用域盖章,
+    /// 规则按 (id, tenant, factory) 绑行 —— 越权 id 一律按不存在处理,不泄露他租户规则是否存在。
+    /// </summary>
+    public async Task<S8WatchRulePreviewResult> PreviewAsync(long id, S8TrustedScope scope)
+    {
+        var rule = await _ruleRep.AsQueryable()
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .FirstAsync() ?? throw new S8NotFoundException();
+
+        var ruleType = rule.RuleType;
+        if (string.IsNullOrWhiteSpace(ruleType))
+            throw new S8BizException("规则未配置 rule_type,无法预演");
+
+        IS8RuleEvaluator evaluator = ruleType switch
+        {
+            S8TimeoutRuleEvaluator.RuleTypeCode => _timeoutEvaluator,
+            S8ShortageRuleEvaluator.RuleTypeCode => _shortageEvaluator,
+            S8OutOfRangeRuleEvaluator.RuleTypeCode => _outOfRangeEvaluator,
+            _ => throw new S8BizException($"不支持的规则类型:{ruleType}")
+        };
+
+        // 与生产同一条取数链。evaluator 内部会再次经 Gateway 取数,
+        // 这里先单独取一次仅为拿到 candidateRows(判定前的行数),用于解释「筛掉了多少」。
+        var maxRows = S8EvaluatorGuard.ResolveMaxRows(null);
+        var data = await _gateway.LoadAsync(scope.TenantId, scope.FactoryId, rule, ruleType, maxRows);
+        var candidateRows = data.RowSet.Rows.Count;
+
+        var hits = await evaluator.EvaluateAsync(scope.TenantId, scope.FactoryId, rule);
+
+        // 逐条回答「若真的建单,是新建还是命中既有活动异常」。
+        // 与 S8WatchSchedulerService 的去重口径一致:同 dedup_key 且未 CLOSED 未删即视为活动。
+        var dedupKeys = hits.Select(h => h.DedupKey).Where(k => !string.IsNullOrWhiteSpace(k)).Distinct().ToList();
+        var activeByDedupKey = dedupKeys.Count == 0
+            ? new Dictionary<string, long>(StringComparer.Ordinal)
+            : (await _exceptionRep.AsQueryable()
+                .Where(x => x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId
+                            && !x.IsDeleted && x.Status != "CLOSED"
+                            && x.DedupKey != null && dedupKeys.Contains(x.DedupKey))
+                .Select(x => new { x.DedupKey, x.Id })
+                .ToListAsync())
+              .GroupBy(x => x.DedupKey!, StringComparer.Ordinal)
+              .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
+
+        return new S8WatchRulePreviewResult
+        {
+            RuleId = rule.Id,
+            RuleCode = rule.RuleCode,
+            RuleType = ruleType,
+            DatasetCode = rule.DatasetCode,
+            Enabled = rule.Enabled,
+            TenantId = scope.TenantId,
+            FactoryId = scope.FactoryId,
+            EvaluatedAt = DateTime.Now,
+            CandidateRows = candidateRows,
+            HitCount = hits.Count,
+            // 未命中数 = 候选 - 命中。刻意不细分「已完成」与「未到期」:
+            // 那需要 preview 自己复刻一遍 evaluator 的判定分支,一旦两边逻辑漂移,
+            // 预演就会给出与真实运行不同的解释。宁可少报,不可假报。
+            NoHitRows = Math.Max(0, candidateRows - hits.Count),
+            WouldCreateCount = hits.Count(h => !activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty)),
+            WouldMatchExistingCount = hits.Count(h => activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty)),
+            Hits = hits.Select(h => new S8WatchRulePreviewHit
+            {
+                SourceObjectType = h.SourceObjectType,
+                SourceObjectId = h.SourceObjectId,
+                RelatedObjectCode = h.RelatedObjectCode,
+                RelatedObjectName = h.Title,
+                ExceptionTypeCode = h.ExceptionTypeCode,
+                Severity = h.Severity,
+                DedupKey = h.DedupKey,
+                DetectedAt = h.DetectedAt,
+                ExistingActiveExceptionId =
+                    activeByDedupKey.TryGetValue(h.DedupKey ?? string.Empty, out var exId) ? exId : null
+            }).ToList()
+        };
+    }
+}
+
+/// <summary>预演结果。纯读,不代表任何已发生的副作用。</summary>
+public sealed class S8WatchRulePreviewResult
+{
+    public long RuleId { get; init; }
+    public string RuleCode { get; init; } = string.Empty;
+    public string RuleType { get; init; } = string.Empty;
+    public string? DatasetCode { get; init; }
+
+    /// <summary>规则当前是否启用。预演**不受此值影响**,也不修改它。</summary>
+    public bool Enabled { get; init; }
+
+    public long TenantId { get; init; }
+    public long FactoryId { get; init; }
+    public DateTime EvaluatedAt { get; init; }
+
+    /// <summary>Provider 返回的候选行数(判定之前)。</summary>
+    public int CandidateRows { get; init; }
+
+    public int HitCount { get; init; }
+    public int NoHitRows { get; init; }
+
+    /// <summary>若此刻真的执行,会新建的异常数。</summary>
+    public int WouldCreateCount { get; init; }
+
+    /// <summary>若此刻真的执行,会命中既有活动异常(即刷新而非新建)的数量。</summary>
+    public int WouldMatchExistingCount { get; init; }
+
+    public List<S8WatchRulePreviewHit> Hits { get; init; } = new();
+}
+
+/// <summary>单条预演命中。</summary>
+public sealed class S8WatchRulePreviewHit
+{
+    public string SourceObjectType { get; init; } = string.Empty;
+    public string SourceObjectId { get; init; } = string.Empty;
+    public string RelatedObjectCode { get; init; } = string.Empty;
+    public string RelatedObjectName { get; init; } = string.Empty;
+    public string ExceptionTypeCode { get; init; } = string.Empty;
+    public string Severity { get; init; } = string.Empty;
+    public string DedupKey { get; init; } = string.Empty;
+    public DateTime DetectedAt { get; init; }
+
+    /// <summary>非空表示该命中已有活动异常,真实执行时是刷新而不是新建。</summary>
+    public long? ExistingActiveExceptionId { get; init; }
+}