Explorar o código

refactor(s8): 删除旧 AlertRule 直连 SQL 链,并按创建来源阻断新增裸 SQL 规则

## 删除(经证实无生产可达性)

S8WatchSchedulerService 中的 G01 旧 AlertRule 执行链整体移除,共 17 个成员 + 3 个 DTO:
LoadExecutionRulesAsync / QueryDeviceRowsAsync(×2) / EvaluateHitsAsync /
EvaluateDedupAsync / FindPendingExceptionIdAsync / RunOnceAsync / CreateSqlQueryScope
及其私有辅助(TryParseTriggerCondition / EvaluateHit / MapDeviceRow / HasRequiredColumns 等),
连同 S8WatchExecutionRule / S8WatchDeviceQueryResult / S8WatchDeviceRow。

删除依据(不止"0 引用"):
- 唯一入口是 AdoS8WatchDebugController.run-once,受 WatchScheduler:DebugEndpointEnabled
  控制,配置为 false 时全部 Action 返回 404;生产 Job 走 RunDispatchTickAsync,
  S8WatchSchedulerJob 中对 CreateExceptionsAsync 的引用只是过期的文档注释
- S8WatchSchedulerService 是 ITransient 而非 IDynamicApiController,
  public 方法不会被动态 API 暴露为 HTTP 端点
- 无反射 / 动态派发 / 配置绑定引用;测试项目仅在 bin 下生成的 XML 文档中出现
- LoadExecutionRulesAsync 的过滤条件(rule_type 为空 + scene=S2 + 设备类对象
  + 同场景恰好一条可运行 AlertRule)在当前数据下恒为空

该链的 SQL 分支自行 CreateSqlQueryScope 执行 rule.expression,绕过 S8DataSourceRowLoader,
是 S8 内最后一处直连 SQL 旁路。删除后 S8 仅剩 S8DataSourceRowLoader 一个 SQL 执行点。

CreateExceptionsAsync 保留(debug run-once 仍用),但只余三类正式 evaluator 路径;
返回结构不变,现有 e2e spec 断言的字段形状不受影响。

## 治理:停止新增 SQL 技术债

新增 S8RuleCreationPolicy + S8RuleCreationOrigin,按创建来源分治:
- EXTERNAL_API(裸 REST POST watch-rules,调用方自带 expression)→ 拒绝,
  reason code legacy_sql_creation_disabled
- WIZARD_GENERATED(wizard-drafts/{id}/generate-rule)→ 暂时放行

不做一刀切的依据:取证显示配置向导仍是在产的规则创建流程,且它生成的正是
LEGACY_SQL 规则(SQL 由服务端按 monitor_object/monitor_metric 字典生成、
标识符经白名单校验,调用方不提供 SQL 文本)。直接封死会阻断在用业务流程。
CreateAsync 的 origin 参数默认取最严格的 EXTERNAL_API(fail-closed)。

治理只作用于新建;存量规则的启停与维护不受约束,
否则数据源一出问题存量规则将再也无法关停。

## 保留

S8LegacySqlDataProvider / S8DataSourceRowLoader / AdoS8DataSource /
rule.expression / rule.data_source_id 全部保留——132 条历史规则仍依赖
data_access_mode=NULL → LEGACY_SQL。无 schema 变更,未触碰活库任何规则。

测试:新增 8 条(含"STANDARD_DATASET 即使 expression 里有 DROP TABLE 也绝不执行"
的探针断言),非 Selenium 用例 119/119 通过。
YY968XX hai 1 día
pai
achega
070e61f002

+ 182 - 0
server/Admin.NET.Test/S8/S8LegacySqlGovernanceTests.cs

@@ -0,0 +1,182 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:停止新增 SQL 技术债。
+///
+/// 治理口径不是"禁止一切 LEGACY_SQL"——132 条历史规则仍需运行,向导也仍在用服务端生成的 SQL。
+/// 真正要掐断的是**调用方自带 expression** 这条路径:它既是技术债来源,也是注入面。
+/// 因此按创建来源区分:
+///   · EXTERNAL_API(裸 REST,调用方自带 SQL)→ 拒绝
+///   · WIZARD_GENERATED(服务端字典白名单生成)→ 暂时放行,随向导迁移后收口
+/// </summary>
+public class S8LegacySqlGovernanceTests
+{
+    private static AdoS8WatchRule LegacyRule() => new()
+    {
+        RuleCode = "UT_NEW_LEGACY",
+        RuleType = "TIMEOUT",
+        DataAccessMode = null,                 // 等价 LEGACY_SQL
+        Expression = "SELECT * FROM some_physical_table WHERE 1=1"
+    };
+
+    private static AdoS8WatchRule StandardRule() => new()
+    {
+        RuleCode = "UT_NEW_STANDARD",
+        RuleType = "TIMEOUT",
+        DataAccessMode = S8DataAccessMode.StandardDataset,
+        DatasetCode = "ANY_DATASET"
+    };
+
+    // ============================================================
+    // C. 新建 Legacy SQL 规则
+    // ============================================================
+
+    [Fact]
+    public void ExternalApi_CreatingLegacySqlRule_IsRejected()
+    {
+        var ex = Assert.Throws<S8BizException>(() =>
+            S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.ExternalApi));
+        Assert.Contains(S8RuleCreationPolicy.LegacySqlCreationDisabledReason, ex.Message);
+    }
+
+    [Fact]
+    public void ExternalApi_CreatingLegacySqlRule_ExplicitMode_IsAlsoRejected()
+    {
+        // 显式写 LEGACY_SQL 与留空等价,不能靠显式声明绕过治理。
+        var rule = LegacyRule();
+        rule.DataAccessMode = S8DataAccessMode.LegacySql;
+        Assert.Throws<S8BizException>(() =>
+            S8RuleCreationPolicy.EnsureCreationAllowed(rule, S8RuleCreationOrigin.ExternalApi));
+    }
+
+    [Fact]
+    public void WizardGenerated_LegacySqlRule_StillAllowed()
+    {
+        // 向导 SQL 由 S8ConfigDraftService 按字典白名单生成,非调用方自带;
+        // 该流程仍在生产使用,本批不阻断(见报告 KNOWN REMAINING SQL DEBT)。
+        S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.WizardGenerated);
+    }
+
+    [Fact]
+    public void StandardDatasetRule_AllowedFromAnyOrigin()
+    {
+        S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.ExternalApi);
+        S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.WizardGenerated);
+    }
+
+    [Fact]
+    public void UnknownOrigin_IsTreatedAsExternal_FailClosed()
+    {
+        // 未知来源按最严格处理:新增调用方不会因为忘记声明来源而意外获得放行。
+        Assert.Throws<S8BizException>(() =>
+            S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), "SOMETHING_NEW"));
+    }
+
+    // ============================================================
+    // D. 历史 Legacy 规则的维护不受治理影响
+    // ============================================================
+
+    [Fact]
+    public void ExistingLegacyRule_CanStillBeDisabled()
+    {
+        // 治理只作用于"新建"。若关停也被拦,数据集/数据源一出问题规则就再也关不掉。
+        var existing = LegacyRule();
+        S8RuleCreationPolicy.EnsureMaintenanceAllowed(existing, enabling: false);
+    }
+
+    [Fact]
+    public void ExistingLegacyRule_CanStillBeReEnabled()
+    {
+        // 历史规则重新启用不属于"新增技术债",不拦。
+        S8RuleCreationPolicy.EnsureMaintenanceAllowed(LegacyRule(), enabling: true);
+    }
+
+    // ============================================================
+    // B. STANDARD_DATASET 即使带恶意 SQL 也绝不执行
+    // ============================================================
+
+    private const string PoisonDataset = "FAKE_POISON_DATASET";
+
+    private static S8DatasetCapabilities TimeoutCapable() => new()
+    {
+        SupportsTimeout = true,
+        HasSourceObjectId = true,
+        HasRelatedObjectCode = true,
+        HasDueAt = true,
+        HasStatus = true
+    };
+
+    private sealed class PoisonSource : IS8DatasetDefinitionSource
+    {
+        public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
+        {
+            new S8DatasetDefinition
+            {
+                DatasetCode = PoisonDataset,
+                DisplayName = "恶意SQL防护测试数据集",
+                Kind = S8DatasetKind.Object,
+                Capabilities = TimeoutCapable()
+            }
+        };
+    }
+
+    private sealed class EmptyProvider : IS8MonitoringDataProvider
+    {
+        public string DatasetCode => PoisonDataset;
+        public S8DatasetCapabilities Capabilities => TimeoutCapable();
+        public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
+        {
+            request.EnsureValid();
+            return Task.FromResult(S8MonitoringRowSet.Empty);
+        }
+    }
+
+    private sealed class SpyLegacy : IS8LegacySqlDataProvider
+    {
+        public int CallCount { get; private set; }
+        public string LastExpression { get; private set; }
+
+        public Task<S8MonitoringDataResult> LoadAsync(
+            long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
+            int timeoutSeconds, CancellationToken cancellationToken = default)
+        {
+            CallCount++;
+            LastExpression = rule.Expression;
+            return Task.FromResult(new S8MonitoringDataResult { RowSet = S8MonitoringRowSet.Empty });
+        }
+    }
+
+    [Fact]
+    public async Task StandardDataset_MaliciousExpression_IsNeverExecuted()
+    {
+        var legacy = new SpyLegacy();
+        var gateway = new S8MonitoringDataGateway(
+            legacy,
+            new S8MonitoringDataProviderRegistry(new[] { new EmptyProvider() }),
+            new S8DatasetCatalog(new[] { new PoisonSource() }),
+            NullLogger<S8MonitoringDataGateway>.Instance);
+
+        var rule = new AdoS8WatchRule
+        {
+            RuleCode = "UT_POISON",
+            RuleType = "TIMEOUT",
+            DataAccessMode = S8DataAccessMode.StandardDataset,
+            DatasetCode = PoisonDataset,
+            // 即使历史遗留字段里躺着可执行 SQL,STANDARD_DATASET 路径也绝不能碰它。
+            Expression = "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN"
+        };
+
+        var result = await gateway.LoadAsync(1, 1, rule, "TIMEOUT", 60, 1000);
+
+        Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode);
+        Assert.Equal(0, legacy.CallCount);
+        Assert.Null(legacy.LastExpression);
+    }
+}

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.479</AssemblyVersion>
-    <FileVersion>1.0.479</FileVersion>
-    <Version>1.0.479</Version>
+    <AssemblyVersion>1.0.480</AssemblyVersion>
+    <FileVersion>1.0.480</FileVersion>
+    <Version>1.0.480</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 75 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8RuleCreationPolicy.cs

@@ -0,0 +1,75 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+/// <summary>
+/// 规则创建来源。用于区分"调用方自带 SQL"与"服务端按字典生成 SQL",
+/// 因为两者的技术债性质与安全面完全不同。
+/// </summary>
+public static class S8RuleCreationOrigin
+{
+    /// <summary>
+    /// 裸 REST 创建(<c>POST /api/aidop/s8/config/watch-rules</c>):调用方可自带任意 expression。
+    /// 这是新增 SQL 技术债的来源,也是注入面。
+    /// </summary>
+    public const string ExternalApi = "EXTERNAL_API";
+
+    /// <summary>
+    /// 配置向导生成(<c>S8ConfigDraftService.GenerateRuleAsync</c>):
+    /// SQL 由服务端按 <c>ado_s8_monitor_object</c> / <c>ado_s8_monitor_metric</c> 字典生成,
+    /// 标识符经 ValidateSqlIdentifier 白名单校验,调用方不提供 SQL 文本。
+    /// </summary>
+    public const string WizardGenerated = "WIZARD_GENERATED";
+}
+
+/// <summary>
+/// S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:新增 SQL 技术债的治理策略。
+///
+/// <b>为什么不是"一刀切禁止 LEGACY_SQL"</b>:取证显示配置向导
+/// (<c>wizard-drafts/{id}/generate-rule</c> → <c>S8WatchRuleService.CreateAsync</c>)
+/// 仍是在产的规则创建流程,且它生成的正是 LEGACY_SQL 规则。直接封死会阻断在用业务流程。
+///
+/// 因此按来源分治:掐断"调用方自带 SQL",保留"服务端字典生成",
+/// 待向导迁移到 STANDARD_DATASET 后再收口最后一条通道。
+///
+/// <b>本策略只作用于"新建"</b>:历史规则的启停与维护不受约束,
+/// 否则数据源一出问题,存量规则将再也无法被关停。
+/// </summary>
+public static class S8RuleCreationPolicy
+{
+    /// <summary>拒绝新增裸 SQL 规则时使用的 reason code。</summary>
+    public const string LegacySqlCreationDisabledReason = "legacy_sql_creation_disabled";
+
+    /// <summary>
+    /// 新建规则时的取数模式治理。
+    /// 未知来源按 <see cref="S8RuleCreationOrigin.ExternalApi"/> 处理(fail-closed):
+    /// 将来新增调用方若忘记声明来源,会被拦下而不是意外获得放行。
+    /// </summary>
+    public static void EnsureCreationAllowed(AdoS8WatchRule rule, string? origin)
+    {
+        // STANDARD_DATASET 是鼓励方向,任何来源都放行。
+        if (S8DataAccessMode.IsAcceptable(rule.DataAccessMode)
+            && S8DataAccessMode.Resolve(rule.DataAccessMode) == S8DataAccessMode.StandardDataset)
+            return;
+
+        if (string.Equals(origin, S8RuleCreationOrigin.WizardGenerated, StringComparison.Ordinal))
+            return;
+
+        throw new S8BizException(
+            $"[{LegacySqlCreationDisabledReason}] 不再接受新建自带 SQL 的监控规则。" +
+            $"请改用 data_access_mode={S8DataAccessMode.StandardDataset} 并引用已注册的 dataset_code;" +
+            "历史 LEGACY_SQL 规则仍可正常运行与维护。");
+    }
+
+    /// <summary>
+    /// 存量规则维护(启用 / 停用 / 改参数)时的治理。
+    /// 当前对两个方向都放行——治理目标是"不再新增旧债",不是"让旧债无法维护"。
+    /// 保留此入口是为了让边界显式:将来若要收紧,改这里而不是散落到各 service。
+    /// </summary>
+    public static void EnsureMaintenanceAllowed(AdoS8WatchRule rule, bool enabling)
+    {
+        // 刻意为空:存量 LEGACY_SQL 规则的启停不受创建治理约束。
+        _ = rule;
+        _ = enabling;
+    }
+}

+ 8 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ConfigDraftService.cs

@@ -6,6 +6,8 @@ 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.Service.S8.Rules.DataAccess;
+
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
 internal static class S8ConfigDraftStatus
@@ -210,7 +212,12 @@ public class S8ConfigDraftService : ITransient
         try
         {
             // S8-TENANT-FACTORY-P0-CLOSURE-1:草稿已按可信作用域绑行,生成的规则沿用同一作用域。
-            var created = await _watchRuleService.CreateAsync(payload, scope);
+            //
+            // S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:显式声明来源为 WizardGenerated。
+            // 本流程的 SQL 由 BuildExpression 按 monitor_object / monitor_metric 字典生成、
+            // 标识符经白名单校验,调用方不提供 SQL 文本,因此不受"禁止新增裸 SQL 规则"约束。
+            // 待向导迁移到 STANDARD_DATASET 后,此处应改回默认(ExternalApi)以收口最后一条通道。
+            var created = await _watchRuleService.CreateAsync(payload, scope, S8RuleCreationOrigin.WizardGenerated);
 
             // S8WatchRuleService.CreateAsync 内部用 InsertAsync 不回填 Id;按 RuleCode 反查真实 id。
             var newRuleId = created.Id;

+ 9 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs

@@ -35,10 +35,18 @@ public class S8WatchRuleService : ITransient
             .ToListAsync();
 
     // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
-    public async Task<AdoS8WatchRule> CreateAsync(AdoS8WatchRule body, S8TrustedScope scope)
+    //
+    // S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:新增 origin 参数用于治理新增 SQL 技术债。
+    // 默认值刻意取最严格的 ExternalApi(fail-closed)——将来新增调用方若忘记声明来源,
+    // 会被拦下而不是意外获得放行。
+    public async Task<AdoS8WatchRule> CreateAsync(
+        AdoS8WatchRule body,
+        S8TrustedScope scope,
+        string origin = S8RuleCreationOrigin.ExternalApi)
     {
         body.TenantId = scope.TenantId;
         body.FactoryId = scope.FactoryId;
+        S8RuleCreationPolicy.EnsureCreationAllowed(body, origin);
         await ValidateAsync(body, scope);
         // S8-STEP6E:新建一律走 canonical 词表 + params schema,杜绝「存得下但运行时永远不生效」。
         ValidateVocabularyForCreate(body);

+ 9 - 511
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -3,7 +3,6 @@ using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 using Microsoft.Extensions.Logging;
 using SqlSugar;
-using System.Data;
 using System.Globalization;
 using System.Text.Json;
 
@@ -18,7 +17,6 @@ public class S8WatchSchedulerService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
     private readonly SqlSugarRepository<AdoS8AlertRule> _alertRuleRep;
-    private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
     private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
     private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
     private readonly S8NotificationService _notificationService;
@@ -60,7 +58,6 @@ public class S8WatchSchedulerService : ITransient
     public S8WatchSchedulerService(
         SqlSugarRepository<AdoS8WatchRule> ruleRep,
         SqlSugarRepository<AdoS8AlertRule> alertRuleRep,
-        SqlSugarRepository<AdoS8DataSource> dataSourceRep,
         SqlSugarRepository<AdoS8Exception> exceptionRep,
         SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
         S8NotificationService notificationService,
@@ -78,7 +75,6 @@ public class S8WatchSchedulerService : ITransient
     {
         _ruleRep = ruleRep;
         _alertRuleRep = alertRuleRep;
-        _dataSourceRep = dataSourceRep;
         _exceptionRep = exceptionRep;
         _exceptionTypeRep = exceptionTypeRep;
         _notificationService = notificationService;
@@ -109,351 +105,28 @@ public class S8WatchSchedulerService : ITransient
             """);
     }
 
-    public async Task<List<S8WatchExecutionRule>> LoadExecutionRulesAsync(long tenantId, long factoryId)
-    {
-        // R3 OUT_OF_RANGE 重写后,新三类(OUT_OF_RANGE/TIMEOUT/SHORTAGE)改走 ProcessRulesByTypeAsync。
-        // 旧 AlertRule 兼容主链此处只装载 RuleType 为空/未分类的历史规则,避免新旧双跑导致重复建单。
-        var watchRules = await _ruleRep.AsQueryable()
-            .Where(x => x.TenantId == tenantId
-                && x.FactoryId == factoryId
-                && x.Enabled
-                && x.SceneCode == S8SceneCode.S2
-                && (x.RuleType == null || x.RuleType == ""))
-            .ToListAsync();
-
-        var deviceRules = watchRules
-            .Where(x => IsDeviceWatchObjectType(x.WatchObjectType))
-            .ToList();
-        if (deviceRules.Count == 0) return new();
-
-        var dataSourceIds = deviceRules
-            .Select(x => x.DataSourceId)
-            .Distinct()
-            .ToList();
-        var dataSources = await _dataSourceRep.AsQueryable()
-            .Where(x => x.TenantId == tenantId
-                && x.FactoryId == factoryId
-                && x.Enabled
-                && dataSourceIds.Contains(x.Id))
-            .ToListAsync();
-        var dataSourceMap = dataSources.ToDictionary(x => x.Id);
-        if (dataSourceMap.Count == 0) return new();
-
-        var alertRules = (await _alertRuleRep.AsQueryable()
-            .Where(x => x.TenantId == tenantId
-                && x.FactoryId == factoryId
-                && x.SceneCode == S8SceneCode.S2)
-            .ToListAsync())
-            .Where(IsSupportedAlertRule)
-            .ToList();
-
-        // G-01 首版 AlertRule 冲突口径(C 收口):
-        // 当前场景存在多条可运行 AlertRule 时,视为“当前规则配置冲突”并跳过该规则,
-        // 不按“首条”继续运行,也不扩大为“整场景停摆”。
-        // 当前模型下所有 device watchRule 共享同场景 AlertRule,故冲突态下所有 device 规则均跳过,
-        // 但此处按“逐规则跳过”的语义实现,避免被误读为“整场景 return empty 停摆”。
-        var alertRule = alertRules.Count == 1 ? alertRules[0] : null;
-
-        var executionRules = new List<S8WatchExecutionRule>();
-        foreach (var watchRule in deviceRules.OrderBy(x => x.Id))
-        {
-            // 配置冲突:当前规则跳过(不停摆其他规则)。
-            if (alertRule == null)
-                continue;
-
-            if (!dataSourceMap.TryGetValue(watchRule.DataSourceId, out var dataSource))
-                continue;
-
-            if (!IsSupportedSqlDataSource(dataSource))
-                continue;
-
-            executionRules.Add(new S8WatchExecutionRule
-            {
-                WatchRuleId = watchRule.Id,
-                WatchRuleCode = watchRule.RuleCode,
-                SceneCode = watchRule.SceneCode,
-                TriggerType = DefaultTriggerType,
-                WatchObjectType = watchRule.WatchObjectType.Trim(),
-                DataSourceId = dataSource.Id,
-                DataSourceCode = dataSource.DataSourceCode,
-                DataSourceType = dataSource.Type,
-                DataSourceConnection = dataSource.Endpoint?.Trim() ?? string.Empty,
-                QueryExpression = watchRule.Expression?.Trim() ?? string.Empty,
-                PollIntervalSeconds = watchRule.PollIntervalSeconds,
-                AlertRuleId = alertRule.Id,
-                AlertRuleCode = alertRule.RuleCode,
-                TriggerCondition = alertRule.TriggerCondition!.Trim(),
-                ThresholdValue = alertRule.ThresholdVal!.Trim(),
-                Severity = alertRule.Severity
-            });
-        }
-
-        return executionRules;
-    }
-
-    public async Task<List<S8WatchDeviceQueryResult>> QueryDeviceRowsAsync(long tenantId, long factoryId)
-    {
-        var executionRules = await LoadExecutionRulesAsync(tenantId, factoryId);
-        var results = new List<S8WatchDeviceQueryResult>();
-        foreach (var rule in executionRules)
-            results.Add(await QueryDeviceRowsAsync(rule));
-
-        return results;
-    }
-
-    public async Task<S8WatchDeviceQueryResult> QueryDeviceRowsAsync(S8WatchExecutionRule rule)
-    {
-        if (!S8DataSourceRowLoader.IsSupportedType(rule.DataSourceType))
-            return S8WatchDeviceQueryResult.Fail(rule, "数据源类型不是 SQL/API,已跳过");
-
-        if (string.IsNullOrWhiteSpace(rule.QueryExpression))
-            return S8WatchDeviceQueryResult.Fail(rule, "查询表达式为空,已跳过");
-
-        try
-        {
-            DataTable table;
-            if (string.Equals(rule.DataSourceType, S8DataSourceRowLoader.ApiType, StringComparison.OrdinalIgnoreCase))
-            {
-                var ds = new AdoS8DataSource
-                {
-                    Type = S8DataSourceRowLoader.ApiType,
-                    Endpoint = rule.DataSourceConnection,
-                    Enabled = true
-                };
-                table = await _rowLoader.LoadAsync(ds, rule.QueryExpression, SqlSugar.DbType.MySql, 30);
-            }
-            else
-            {
-                using var db = CreateSqlQueryScope(rule.DataSourceConnection);
-                table = await db.Ado.GetDataTableAsync(rule.QueryExpression);
-            }
-            if (!HasRequiredColumns(table))
-                return S8WatchDeviceQueryResult.Fail(rule, "查询结果缺少 required columns: related_object_code/current_value");
-
-            var rows = table.Rows.Cast<DataRow>()
-                .Select(MapDeviceRow)
-                .Where(x => !string.IsNullOrWhiteSpace(x.RelatedObjectCode))
-                .ToList();
-            return S8WatchDeviceQueryResult.Ok(rule, rows);
-        }
-        catch (Exception ex)
-        {
-            return S8WatchDeviceQueryResult.Fail(rule, $"查询执行失败: {ex.Message}");
-        }
-    }
-
-    /// <summary>
-    /// G01-04:基于设备级结果行集做首版 VALUE_DEVIATION 单阈值判定,
-    /// 产出命中结果对象列表,供 G01-05 去重与 G01-06 建单消费。
-    /// 本方法不做去重、不做建单、不做严重度重算、不做时间线。
-    /// </summary>
-    public async Task<List<S8WatchHitResult>> EvaluateHitsAsync(long tenantId, long factoryId)
-    {
-        var executionRules = await LoadExecutionRulesAsync(tenantId, factoryId);
-        var ruleMap = executionRules.ToDictionary(x => x.WatchRuleId);
-        var queryResults = new List<S8WatchDeviceQueryResult>();
-        foreach (var rule in executionRules)
-            queryResults.Add(await QueryDeviceRowsAsync(rule));
-
-        var hits = new List<S8WatchHitResult>();
-        foreach (var queryResult in queryResults)
-        {
-            // G01-03 查询失败:跳过,不进入判定。
-            if (!queryResult.Success) continue;
-
-            if (!ruleMap.TryGetValue(queryResult.WatchRuleId, out var rule)) continue;
-
-            // 判定参数缺失:跳过当前规则。
-            if (string.IsNullOrWhiteSpace(rule.TriggerCondition)
-                || string.IsNullOrWhiteSpace(rule.ThresholdValue))
-                continue;
 
-            // 比较符非法:跳过当前规则。
-            var op = TryParseTriggerCondition(rule.TriggerCondition);
-            if (op == null) continue;
 
-            // ThresholdValue 非法:跳过当前规则。
-            if (!TryParseDecimal(rule.ThresholdValue, out var threshold)) continue;
-
-            foreach (var row in queryResult.Rows)
-            {
-                // CurrentValue 非法(null / 无法解析数值):跳过当前行,不进入判定。
-                if (row.CurrentValue == null) continue;
-
-                // 未命中:不进入后续链路。
-                if (!EvaluateHit(row.CurrentValue.Value, op, threshold)) continue;
-
-                hits.Add(new S8WatchHitResult
-                {
-                    SourceRuleId = rule.WatchRuleId,
-                    SourceRuleCode = rule.WatchRuleCode,
-                    AlertRuleId = rule.AlertRuleId,
-                    DataSourceId = rule.DataSourceId,
-                    RelatedObjectCode = row.RelatedObjectCode,
-                    CurrentValue = row.CurrentValue.Value,
-                    ThresholdValue = threshold,
-                    TriggerCondition = op,
-                    Severity = rule.Severity,
-                    OccurrenceDeptId = row.OccurrenceDeptId,
-                    ResponsibleDeptId = row.ResponsibleDeptId,
-                    SourcePayload = row.SourcePayload
-                });
-            }
-        }
 
-        return hits;
-    }
 
-    /// <summary>
-    /// G01-05:未闭环异常去重最小实现。
-    /// 消费 G01-04 产出的 <see cref="S8WatchHitResult"/>,按 (SourceRuleId + RelatedObjectCode)
-    /// 在未闭环状态集合内判重,只回答“是否允许建单”。
-    /// 首版明确不做:原单刷新 / 时间线追加 / payload 更新 / 次数累计 / 严重度重算 / 状态修复。
-    /// </summary>
-    public async Task<List<S8WatchDedupResult>> EvaluateDedupAsync(long tenantId, long factoryId)
-    {
-        var hits = await EvaluateHitsAsync(tenantId, factoryId);
-        var results = new List<S8WatchDedupResult>(hits.Count);
-        foreach (var hit in hits)
-        {
-            // 防御性分支:正常情况下 SourceRuleId 与 RelatedObjectCode 已由上游
-            // (G01-02 规则装配 + G01-03 查询结果列校验)保证;此处仅作兜底,
-            // 不是首版正常路径。
-            if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
-            {
-                results.Add(new S8WatchDedupResult
-                {
-                    Hit = hit,
-                    CanCreate = false,
-                    MatchedExceptionId = null,
-                    Reason = "missing_dedup_key"
-                });
-                continue;
-            }
-
-            long matchedId;
-            try
-            {
-                matchedId = await FindPendingExceptionIdAsync(
-                    tenantId, factoryId, hit.SourceRuleId, hit.RelatedObjectCode);
-            }
-            catch
-            {
-                // 去重查询失败:首版偏保守,宁可阻止也不重复建单,不扩展为补偿。
-                results.Add(new S8WatchDedupResult
-                {
-                    Hit = hit,
-                    CanCreate = false,
-                    MatchedExceptionId = null,
-                    Reason = "query_failed"
-                });
-                continue;
-            }
-
-            if (matchedId > 0)
-            {
-                // 命中已有未闭环异常 → 阻止建单。
-                results.Add(new S8WatchDedupResult
-                {
-                    Hit = hit,
-                    CanCreate = false,
-                    MatchedExceptionId = matchedId,
-                    Reason = "duplicate_pending"
-                });
-            }
-            else
-            {
-                // 未命中 → 允许建单,交 G01-06 消费。
-                results.Add(new S8WatchDedupResult
-                {
-                    Hit = hit,
-                    CanCreate = true,
-                    MatchedExceptionId = null,
-                    Reason = "no_pending"
-                });
-            }
-        }
-
-        return results;
-    }
 
     // 取任意一条匹配的未闭环异常 Id 作为“是否存在重复单”的拦截依据。
     // 首版只需要“存在性”,不关心“最早 / 最新”;不在 G01-05 处理排序语义。
-    private async Task<long> FindPendingExceptionIdAsync(
-        long tenantId, long factoryId, long sourceRuleId, string relatedObjectCode)
-    {
-        // SqlSugar 表达式翻译要求 Contains 数组变量必须可访问;此处将类级常量
-        // 承接到方法内局部变量,仅为表达式翻译服务,值与 UnclosedExceptionStatuses 一致。
-        var statuses = UnclosedExceptionStatuses;
-        var ids = await _exceptionRep.AsQueryable()
-            .Where(x => x.TenantId == tenantId
-                && x.FactoryId == factoryId
-                && !x.IsDeleted
-                && x.SourceRuleId == sourceRuleId
-                && x.RelatedObjectCode == relatedObjectCode
-                && statuses.Contains(x.Status))
-            .Select(x => x.Id)
-            .Take(1)
-            .ToListAsync();
-        return ids.Count > 0 ? ids[0] : 0L;
-    }
 
     /// <summary>
-    /// G01-06:自动建单入口。消费 G01-05 去重结果,对 CanCreate==true 的命中
-    /// 复用 S8ManualReportService.CreateFromWatchAsync(同一主链的自动建单分支)落成标准 AdoS8Exception。
-    /// CanCreate==false 直接跳过;创建失败返回最小失败结果,不补偿、不重试、不对账。
+    /// 自动建单入口(debug run-once 使用;生产调度走 <see cref="RunDispatchTickAsync"/>)。
+    ///
+    /// S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:原先此处还有一段旧 AlertRule 兼容主链
+    /// (EvaluateDedupAsync → EvaluateHitsAsync → QueryDeviceRowsAsync → 直连 SQL),
+    /// 该链绕过 <see cref="S8DataSourceRowLoader"/> 自行开 SqlSugarScope 执行 rule.expression,
+    /// 且只装载 rule_type 为空的未分类历史规则 + 需同场景恰好一条可运行 AlertRule。
+    /// 经证实其唯一入口是 404 门禁的 debug controller,且 LoadExecutionRulesAsync 的过滤条件
+    /// 在当前数据下恒返回空,已整体删除。本方法现在只保留三类正式 evaluator 路径。
     /// </summary>
     public async Task<List<S8WatchCreationResult>> CreateExceptionsAsync(long tenantId, long factoryId)
     {
-        var dedupResults = await EvaluateDedupAsync(tenantId, factoryId);
-        var results = new List<S8WatchCreationResult>(dedupResults.Count);
-        foreach (var dedup in dedupResults)
-        {
-            if (!dedup.CanCreate)
-            {
-                results.Add(new S8WatchCreationResult
-                {
-                    DedupResult = dedup,
-                    Created = false,
-                    Skipped = true,
-                    CreatedExceptionId = null,
-                    Reason = dedup.Reason,
-                    ErrorMessage = null
-                });
-                continue;
-            }
-
-            try
-            {
-                var entity = await _manualReportService.CreateFromWatchAsync(tenantId, factoryId, dedup.Hit);
-                await TryDispatchLayerNotificationAsync(entity);
-                results.Add(new S8WatchCreationResult
-                {
-                    DedupResult = dedup,
-                    Created = true,
-                    Skipped = false,
-                    CreatedExceptionId = entity.Id,
-                    Reason = "auto_created",
-                    ErrorMessage = null
-                });
-            }
-            catch (Exception ex)
-            {
-                results.Add(new S8WatchCreationResult
-                {
-                    DedupResult = dedup,
-                    Created = false,
-                    Skipped = false,
-                    CreatedExceptionId = null,
-                    Reason = "create_failed",
-                    ErrorMessage = ex.Message
-                });
-            }
-        }
+        var results = new List<S8WatchCreationResult>();
 
-        // R3-OUT_OF_RANGE-REWRITE-1:三类正式 evaluator 路径。
-        // 旧 AlertRule 兼容主链(上方 dedupResults 循环)已在 LoadExecutionRulesAsync 处过滤掉
-        // OUT_OF_RANGE/TIMEOUT/SHORTAGE,只保留未分类历史规则;故新旧不双跑。
         // R6 RunId:本次 CreateExceptionsAsync 调用对应的统一关联 id,落入 detection_log。
         var runId = Guid.NewGuid().ToString("N").Substring(0, 16);
         results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, runId));
@@ -1010,147 +683,25 @@ public class S8WatchSchedulerService : ITransient
         SourcePayload = hit.SourcePayload
     };
 
-    /// <summary>
-    /// 单次轮询入口。当前仅完成规则读取与组装,返回可执行规则数量,不做实际数据采集。
-    /// </summary>
-    public async Task<int> RunOnceAsync()
-    {
-        var count = 0;
-        foreach (var scope in await ListEnabledScopesAsync())
-            count += (await LoadExecutionRulesAsync(scope.TenantId, scope.FactoryId)).Count;
-        return count;
-    }
 
     // G01-04 首版最小比较符集合:>, >=, <, <=。
     // 允许首尾空格;非此集合的一律视为“比较符非法”,由调用方跳过。
-    private static string? TryParseTriggerCondition(string raw)
-    {
-        var normalized = raw.Trim();
-        return normalized switch
-        {
-            ">" => ">",
-            ">=" => ">=",
-            "<" => "<",
-            "<=" => "<=",
-            _ => null
-        };
-    }
 
-    private static bool TryParseDecimal(string raw, out decimal value) =>
-        decimal.TryParse(raw.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out value);
 
-    private static bool EvaluateHit(decimal current, string op, decimal threshold) => op switch
-    {
-        ">" => current > threshold,
-        ">=" => current >= threshold,
-        "<" => current < threshold,
-        "<=" => current <= threshold,
-        _ => false
-    };
 
-    private static bool IsSupportedAlertRule(AdoS8AlertRule alertRule) =>
-        !string.IsNullOrWhiteSpace(alertRule.TriggerCondition)
-        && !string.IsNullOrWhiteSpace(alertRule.ThresholdVal);
 
-    private static bool IsSupportedSqlDataSource(AdoS8DataSource dataSource) =>
-        dataSource.Enabled
-        && S8DataSourceRowLoader.IsSupportedType(dataSource.Type)
-        && !string.IsNullOrWhiteSpace(dataSource.Endpoint);
 
-    private static bool IsDeviceWatchObjectType(string? watchObjectType)
-    {
-        if (string.IsNullOrWhiteSpace(watchObjectType)) return false;
 
-        var normalized = watchObjectType.Trim().ToUpperInvariant();
-        return normalized is "DEVICE" or "EQUIPMENT" || watchObjectType.Trim() == "设备";
-    }
 
-    private SqlSugarScope CreateSqlQueryScope(string connectionString)
-    {
-        var dbType = _ruleRep.Context.CurrentConnectionConfig.DbType;
-        return new SqlSugarScope(new ConnectionConfig
-        {
-            ConfigId = $"s8-watch-sql-{Guid.NewGuid():N}",
-            DbType = dbType,
-            ConnectionString = connectionString,
-            InitKeyType = InitKeyType.Attribute,
-            IsAutoCloseConnection = true
-        });
-    }
-
-    private static bool HasRequiredColumns(DataTable table) =>
-        TryGetColumnName(table.Columns, "related_object_code") != null
-        && TryGetColumnName(table.Columns, "current_value") != null;
-
-    private static S8WatchDeviceRow MapDeviceRow(DataRow row)
-    {
-        var columns = row.Table.Columns;
-        var relatedObjectCodeColumn = TryGetColumnName(columns, "related_object_code");
-        var currentValueColumn = TryGetColumnName(columns, "current_value");
-        var occurrenceDeptIdColumn = TryGetColumnName(columns, "occurrence_dept_id");
-        var responsibleDeptIdColumn = TryGetColumnName(columns, "responsible_dept_id");
-
-        return new S8WatchDeviceRow
-        {
-            RelatedObjectCode = ReadString(row, relatedObjectCodeColumn),
-            CurrentValue = ReadDecimal(row, currentValueColumn),
-            OccurrenceDeptId = ReadLong(row, occurrenceDeptIdColumn),
-            ResponsibleDeptId = ReadLong(row, responsibleDeptIdColumn),
-            SourcePayload = BuildSourcePayload(row)
-        };
-    }
 
-    private static string? TryGetColumnName(DataColumnCollection columns, string expectedName)
-    {
-        var normalizedExpected = NormalizeColumnName(expectedName);
-        foreach (DataColumn column in columns)
-        {
-            if (NormalizeColumnName(column.ColumnName) == normalizedExpected)
-                return column.ColumnName;
-        }
 
-        return null;
-    }
 
     private static string NormalizeColumnName(string columnName) =>
         columnName.Replace("_", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
 
-    private static string ReadString(DataRow row, string? columnName)
-    {
-        if (string.IsNullOrWhiteSpace(columnName)) return string.Empty;
-        var value = row[columnName];
-        return value == DBNull.Value ? string.Empty : Convert.ToString(value)?.Trim() ?? string.Empty;
-    }
 
-    private static long? ReadLong(DataRow row, string? columnName)
-    {
-        if (string.IsNullOrWhiteSpace(columnName)) return null;
-        var value = row[columnName];
-        if (value == DBNull.Value) return null;
-        return long.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), out var result) ? result : null;
-    }
 
-    private static decimal? ReadDecimal(DataRow row, string? columnName)
-    {
-        if (string.IsNullOrWhiteSpace(columnName)) return null;
-        var value = row[columnName];
-        if (value == DBNull.Value) return null;
-        return decimal.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var result)
-            ? result
-            : null;
-    }
 
-    private static string BuildSourcePayload(DataRow row)
-    {
-        var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
-        foreach (DataColumn column in row.Table.Columns)
-        {
-            var value = row[column];
-            payload[column.ColumnName] = value == DBNull.Value ? null : value;
-        }
-
-        return JsonSerializer.Serialize(payload);
-    }
 
     // ============================================================
     // S8-SCHED-EXEC-1:DB 驱动调度执行层
@@ -1807,61 +1358,8 @@ public class S8WatchSchedulerService : ITransient
     }
 }
 
-public sealed class S8WatchExecutionRule
-{
-    public long WatchRuleId { get; set; }
-    public string WatchRuleCode { get; set; } = string.Empty;
-    public string SceneCode { get; set; } = string.Empty;
-    public string TriggerType { get; set; } = string.Empty;
-    public string WatchObjectType { get; set; } = string.Empty;
-    public long DataSourceId { get; set; }
-    public string DataSourceCode { get; set; } = string.Empty;
-    public string DataSourceType { get; set; } = string.Empty;
-    public string DataSourceConnection { get; set; } = string.Empty;
-    public string QueryExpression { get; set; } = string.Empty;
-    public int PollIntervalSeconds { get; set; }
-    public long AlertRuleId { get; set; }
-    public string AlertRuleCode { get; set; } = string.Empty;
-    public string TriggerCondition { get; set; } = string.Empty;
-    public string ThresholdValue { get; set; } = string.Empty;
-    public string Severity { get; set; } = string.Empty;
-}
 
-public sealed class S8WatchDeviceQueryResult
-{
-    public long WatchRuleId { get; set; }
-    public string WatchRuleCode { get; set; } = string.Empty;
-    public bool Success { get; set; }
-    public string? FailureReason { get; set; }
-    public List<S8WatchDeviceRow> Rows { get; set; } = new();
-
-    public static S8WatchDeviceQueryResult Ok(S8WatchExecutionRule rule, List<S8WatchDeviceRow> rows) =>
-        new()
-        {
-            WatchRuleId = rule.WatchRuleId,
-            WatchRuleCode = rule.WatchRuleCode,
-            Success = true,
-            Rows = rows
-        };
 
-    public static S8WatchDeviceQueryResult Fail(S8WatchExecutionRule rule, string reason) =>
-        new()
-        {
-            WatchRuleId = rule.WatchRuleId,
-            WatchRuleCode = rule.WatchRuleCode,
-            Success = false,
-            FailureReason = reason
-        };
-}
-
-public sealed class S8WatchDeviceRow
-{
-    public string RelatedObjectCode { get; set; } = string.Empty;
-    public decimal? CurrentValue { get; set; }
-    public long? OccurrenceDeptId { get; set; }
-    public long? ResponsibleDeptId { get; set; }
-    public string SourcePayload { get; set; } = string.Empty;
-}
 
 /// <summary>
 /// G01-05 去重结果对象。仅服务 G01-06 建单前拦截,由 CanCreate 单决策位决定是否建单。