ソースを参照

feat(s8): 固化规则定义并收口运行参数写入

监控规则的业务语义此前完全存放在 ado_s8_watch_rule 与 params_json 里:
rule_type / dataset_code / source_object_type 是列,而"到期时间取哪一列、
哪些状态算已完成、去重身份是什么、建什么异常类型"埋在 params_json 这个
自由文本中。于是"改判定口径"与"改轮询间隔"共用同一个配置接口、同一份权限、
同一次保存——业务用户在页面上把 completedStates 改成 ["OPEN"],规则立刻
换一套业务含义,没有任何评审。

本批把规则定义搬进代码,运行参数收口成白名单:

- 新增 S8RuleDefinition / IS8RuleDefinitionSource / S8RuleCatalog,
  镜像既有 IS8DatasetDefinitionSource / S8DatasetCatalog 的形态,
  不引入第二套风格、不引入 DSL 与表达式。
- Rule 01(RULE_S4_PURCHASE_DELIVERY_DATE_DELAY)成为第一条代码定义规则,
  数据集 / 类型 / 源对象类型 / 场景 / 阶段 / 异常类型 / 判定列 /
  completedStates / 去重身份全部钉死在 S8PurchaseDeliveryRuleDefinitions。
- 三个 evaluator 与调度器分派改从定义取语义;DB 上的同名列降级为
  provisioning 维护的只读投影,手工改它不再影响规则行为。
- 没有代码定义的规则不得进入执行链,理由码 rule_definition_not_found。
  这是 Create API 仍存在期间的安全过渡。
- params_json 只保留真正的运行策略(graceMinutes 与两个部门兜底),
  旧 A 类字段读到即忽略、一次正常保存后自然消失,无需 migration。
  params_json 为 NULL 不再抛 rule_not_configured。

同时修复 G1:PUT /{id}/params 原本是"整块覆盖 + 缺字段即置 NULL",
一次 {"enabled":false} 就会把规则的全部业务配置抹成 NULL(本地 Rule 01
13:34 还在正常命中,13:40 关一次开关后 params_json 即为 NULL)。现在
启停拆成独立且幂等的 /enable 与 /disable,只写 enabled 与调度触发时间;
参数接口改强类型白名单 PATCH 语义,未提供的字段保持原值,出现 Definition
字段一律显式 400 而非静默忽略。参数叠加抽成纯函数,可不接数据库逐字段断言。

Catalog 在启动期强制解析并 fail-fast;rule_type 改为 ordinal 精确匹配,
避免 "timeout" 这类大小写错误存得下却在运行期报 unsupported_rule_type。

本批不含:Tenant-only 改造、Factory 清理、Rule provisioning、
Create/Delete/Wizard 退役、前端规则页重构、任何 DB migration。
前端启停按钮的适配排在 Batch 4。

chore: bump version server 1.0.489
YY968XX 3 日 前
コミット
c81596005c
28 ファイル変更2159 行追加577 行削除
  1. 61 34
      server/Admin.NET.Test/S8/S8MonitoringDataContractTests.cs
  2. 13 9
      server/Admin.NET.Test/S8/S8PurchaseDeliveryEndToEndTests.cs
  3. 20 15
      server/Admin.NET.Test/S8/S8PurchaseDeliveryProviderMappingTests.cs
  4. 339 0
      server/Admin.NET.Test/S8/S8RuleDefinitionContractTests.cs
  5. 114 36
      server/Admin.NET.Test/S8/S8TimeoutEvaluatorTests.cs
  6. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  7. 316 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleParameterContractTests.cs
  8. 6 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRuleWriteGuardTests.cs
  9. 45 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs
  10. 32 17
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs
  11. 13 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/IS8RuleDefinitionSource.cs
  12. 93 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8PurchaseDeliveryRuleDefinitions.cs
  13. 129 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8RuleCatalog.cs
  14. 184 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8RuleDefinition.cs
  15. 36 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8EffectiveRule.cs
  16. 0 66
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeParams.cs
  17. 64 67
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs
  18. 163 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleRuntimeParameters.cs
  19. 0 34
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageParams.cs
  20. 58 47
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs
  21. 0 41
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutParams.cs
  22. 76 59
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs
  23. 143 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RuleParametersPayload.cs
  24. 0 39
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleParamsPayload.cs
  25. 10 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs
  26. 196 94
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs
  27. 26 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs
  28. 19 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

+ 61 - 34
server/Admin.NET.Test/S8/S8MonitoringDataContractTests.cs

@@ -1,6 +1,7 @@
 using System.Data;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 using Xunit;
 
@@ -123,6 +124,41 @@ public class S8MonitoringDataContractTests
         RuleType = ruleType
     };
 
+    /// <summary>
+    /// S8-RULE-GOVERNANCE-BATCH1:判定语义已迁至代码定义。
+    /// 这些用例原先手写一份 params_json 来表达"这条规则判什么",现在改为构造对应的
+    /// <see cref="S8RuleDefinition"/> —— 断言内容一字未改,只是语义来源换成了正确的那一处。
+    /// </summary>
+    private static S8EffectiveRule Effective(
+        string ruleType,
+        string exceptionTypeCode = "X",
+        string[]? completedStates = null,
+        decimal? upperBound = null,
+        decimal? lowerBound = null)
+    {
+        var definition = new S8RuleDefinition
+        {
+            RuleCode = "UT_DATASET_RULE",
+            DisplayName = "单测规则",
+            Description = "契约测试用",
+            DatasetCode = "UT_FAKE_DATASET",
+            RuleType = ruleType,
+            RuleMechanism = "DATE",
+            SourceObjectType = "ORDER",
+            SceneCode = "S1",
+            StageCode = "S1",
+            ExceptionTypeCode = exceptionTypeCode,
+            Timeout = ruleType == "TIMEOUT"
+                ? new S8TimeoutSemantics { CompletedStates = completedStates ?? Array.Empty<string>() }
+                : null,
+            Shortage = ruleType == "SHORTAGE" ? new S8ShortageSemantics() : null,
+            OutOfRange = ruleType == "OUT_OF_RANGE"
+                ? new S8OutOfRangeSemantics { UpperBound = upperBound, LowerBound = lowerBound }
+                : null
+        };
+        return S8EffectiveRule.Resolve(NewRule(ruleType), definition);
+    }
+
     [Fact]
     public void Timeout_EvaluateRows_ProducesHit_WithoutAnySql()
     {
@@ -137,11 +173,9 @@ public class S8MonitoringDataContractTests
                 ["status"] = "RUNNING"
             })
         });
-        var parameters = S8TimeoutParams.Parse(
-            """{"dueAtField":"due_at","statusField":"status","completedStates":["COMPLETED"],"exceptionTypeCode":"DELIVERY_DELAY"}""");
-
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("TIMEOUT"),
+            rowSet,
+            Effective("TIMEOUT", exceptionTypeCode: "DELIVERY_DELAY", completedStates: new[] { "COMPLETED" }),
             tenantId: 11, factoryId: 22,
             detectedAt: new DateTime(2026, 6, 1, 0, 0, 0));
 
@@ -165,19 +199,26 @@ public class S8MonitoringDataContractTests
                 ["status"] = "COMPLETED"
             })
         });
-        var parameters = S8TimeoutParams.Parse(
-            """{"dueAtField":"due_at","statusField":"status","completedStates":["COMPLETED"],"exceptionTypeCode":"X"}""");
-
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("TIMEOUT"), 11, 22, new DateTime(2026, 6, 1));
+            rowSet, Effective("TIMEOUT", completedStates: new[] { "COMPLETED" }),
+            11, 22, new DateTime(2026, 6, 1));
 
         Assert.Empty(hits);
     }
 
+    /// <summary>
+    /// S8-RULE-GOVERNANCE-BATCH1:原用例 <c>Timeout_EvaluateRows_FallsBackToParamsColumn_WhenCanonicalAbsent</c>
+    /// 验证的是「结果集没有 canonical 列时,回退到 params_json 指定的真实列名」。
+    ///
+    /// <para>那条回退已随本批**刻意移除**:它是"用户可以在 JSON 里指定判定读哪一列"这项能力的
+    /// 最后一段实现,而该能力正是本批要取消的。Provider 现在必须按 canonical 契约产出行
+    /// (由 <c>S8PurchaseDeliverySqlMappingTests</c> 守护),不存在"结果集没有 canonical 列"的合法情形。</para>
+    ///
+    /// <para>本用例把新语义钉住:非 canonical 列名一律读不到,该行不命中 —— 而不是悄悄换一列读。</para>
+    /// </summary>
     [Fact]
-    public void Timeout_EvaluateRows_FallsBackToParamsColumn_WhenCanonicalAbsent()
+    public void Timeout_EvaluateRows_NoLongerFallsBackToNonCanonicalColumns()
     {
-        // 历史未别名规则:结果集没有 due_at/status,只能按 params 指定的真实列名读取。
         var rowSet = S8MonitoringRowSet.FromRows(new[]
         {
             S8MonitoringRow.FromValues(new Dictionary<string, object>
@@ -187,14 +228,11 @@ public class S8MonitoringDataContractTests
                 ["morder_state"] = "RUNNING"
             })
         });
-        var parameters = S8TimeoutParams.Parse(
-            """{"dueAtField":"planner_end_date","statusField":"morder_state","exceptionTypeCode":"X"}""");
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("TIMEOUT"), 1, 1, new DateTime(2026, 6, 1));
+            rowSet, Effective("TIMEOUT"), 1, 1, new DateTime(2026, 6, 1));
 
-        Assert.Single(hits);
-        Assert.Equal("WO-1", hits[0].SourceObjectId);
+        Assert.Empty(hits);
     }
 
     [Fact]
@@ -209,11 +247,9 @@ public class S8MonitoringDataContractTests
                 ["measured_value"] = 130m
             })
         });
-        var parameters = S8OutOfRangeParams.Parse(
-            """{"measuredValueField":"measured_value","upperBound":100,"exceptionTypeCode":"QUALITY_OUT_OF_SPEC"}""");
-
         var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("OUT_OF_RANGE"), 11, 22, DateTime.Now);
+            rowSet, Effective("OUT_OF_RANGE", exceptionTypeCode: "QUALITY_OUT_OF_SPEC", upperBound: 100m),
+            11, 22, DateTime.Now);
 
         var hit = Assert.Single(hits);
         Assert.Equal("ITEM-1", hit.SourceObjectId);
@@ -233,11 +269,9 @@ public class S8MonitoringDataContractTests
                 ["measured_value"] = 50m
             })
         });
-        var parameters = S8OutOfRangeParams.Parse(
-            """{"measuredValueField":"measured_value","upperBound":100,"lowerBound":10}""");
-
         var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("OUT_OF_RANGE"), 1, 1, DateTime.Now);
+            rowSet, Effective("OUT_OF_RANGE", upperBound: 100m, lowerBound: 10m),
+            1, 1, DateTime.Now);
 
         Assert.Empty(hits);
     }
@@ -254,11 +288,9 @@ public class S8MonitoringDataContractTests
                 ["actual_qty"] = 40m
             })
         });
-        var parameters = S8ShortageParams.Parse(
-            """{"targetQtyField":"target_qty","actualQtyField":"actual_qty","exceptionTypeCode":"MATERIAL_SHORTAGE"}""");
-
         var hits = S8ShortageRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("SHORTAGE"), 11, 22, DateTime.Now);
+            rowSet, Effective("SHORTAGE", exceptionTypeCode: "MATERIAL_SHORTAGE"),
+            11, 22, DateTime.Now);
 
         var hit = Assert.Single(hits);
         Assert.Equal("PO-1", hit.SourceObjectId);
@@ -280,11 +312,8 @@ public class S8MonitoringDataContractTests
                 ["extra_business_column"] = "must-survive"
             })
         });
-        var parameters = S8TimeoutParams.Parse(
-            """{"dueAtField":"due_at","statusField":"status","exceptionTypeCode":"X"}""");
-
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("TIMEOUT"), 1, 1, new DateTime(2026, 6, 1));
+            rowSet, Effective("TIMEOUT"), 1, 1, new DateTime(2026, 6, 1));
 
         Assert.Contains("extra_business_column", hits[0].SourcePayload);
         Assert.Contains("must-survive", hits[0].SourcePayload);
@@ -408,9 +437,7 @@ public class S8MonitoringDataContractTests
         });
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet,
-            S8TimeoutParams.Parse("""{"dueAtField":"due_at","statusField":"status","exceptionTypeCode":"X"}"""),
-            NewRule("TIMEOUT"), 11, 22, new DateTime(2026, 6, 1));
+            rowSet, Effective("TIMEOUT"), 11, 22, new DateTime(2026, 6, 1));
 
         Assert.Single(hits);
         Assert.Equal("T11:F22:RUT_DATASET_RULE:ORDER:T11-F22", hits[0].DedupKey);

+ 13 - 9
server/Admin.NET.Test/S8/S8PurchaseDeliveryEndToEndTests.cs

@@ -2,6 +2,7 @@ using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 using Microsoft.Extensions.Logging.Abstractions;
 using Xunit;
 
@@ -43,14 +44,17 @@ public class S8PurchaseDeliveryEndToEndTests
     /// <summary>评估基准时刻。固定值而非 DateTime.Now:判定结果必须可复现。</summary>
     private static readonly DateTime EvaluatedAt = new(2026, 9, 6, 12, 0, 0);
 
-    /// <summary>Rule 01 在库中的真实 params_json(逐字,含三个终态)。</summary>
-    private static S8TimeoutParams Params() => S8TimeoutParams.Parse(
-        """
-        {"dueAtField":"due_at","statusField":"status",
-         "completedStates":["COMPLETED","CLOSED","CANCELLED"],
-         "objectCodeField":"related_object_code","objectIdField":"source_object_id",
-         "graceMinutes":0,"exceptionTypeCode":"PURCHASE_DELIVERY_ABNORMAL"}
-        """);
+
+    /// <summary>
+    /// S8-RULE-GOVERNANCE-BATCH1:判定语义已迁至代码定义,evaluator 不再解析 params_json 的 A 类字段。
+    /// 本 helper 用 Rule 01 的**正式定义**构造生效规则,因此这些用例现在同时也在守护
+    /// 「定义与实际判定一致」——比原先直接喂一份手写 params_json 更接近真实运行。
+    /// </summary>
+    private static S8EffectiveRule Effective(AdoS8WatchRule rule) =>
+        S8EffectiveRule.Resolve(
+            rule,
+            new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
+                .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode));
 
     private static AdoS8WatchRule Rule01() => new()
     {
@@ -123,7 +127,7 @@ public class S8PurchaseDeliveryEndToEndTests
             TenantId, FactoryId, rule, S8TimeoutRuleEvaluator.RuleTypeCode, maxRows: 1000);
 
         return S8TimeoutRuleEvaluator.EvaluateRows(
-            data.RowSet, Params(), rule, TenantId, FactoryId, EvaluatedAt);
+            data.RowSet, Effective(rule), TenantId, FactoryId, EvaluatedAt);
     }
 
     // ============================================================

+ 20 - 15
server/Admin.NET.Test/S8/S8PurchaseDeliveryProviderMappingTests.cs

@@ -2,6 +2,7 @@ using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 using Microsoft.Extensions.Logging.Abstractions;
 using Xunit;
 
@@ -52,13 +53,17 @@ public class S8PurchaseDeliveryProviderMappingTests
         DatasetCode = PurchaseDelivery
     };
 
-    private static S8TimeoutParams Params() => S8TimeoutParams.Parse(
-        """
-        {"dueAtField":"due_at","statusField":"status",
-         "completedStates":["COMPLETED","CLOSED","CANCELLED"],
-         "objectCodeField":"related_object_code","objectIdField":"source_object_id",
-         "graceMinutes":0,"exceptionTypeCode":"PURCHASE_DELIVERY_ABNORMAL"}
-        """);
+
+    /// <summary>
+    /// S8-RULE-GOVERNANCE-BATCH1:判定语义已迁至代码定义,evaluator 不再解析 params_json 的 A 类字段。
+    /// 本 helper 用 Rule 01 的**正式定义**构造生效规则,因此这些用例现在同时也在守护
+    /// 「定义与实际判定一致」——比原先直接喂一份手写 params_json 更接近真实运行。
+    /// </summary>
+    private static S8EffectiveRule Effective(AdoS8WatchRule rule) =>
+        S8EffectiveRule.Resolve(
+            rule,
+            new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
+                .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode));
 
     /// <summary>真实 DWD 行样本(取自 UAT 租户 2026-09-05 快照)。</summary>
     private static S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow Row(
@@ -300,7 +305,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         var hit = Assert.Single(hits);
         Assert.Equal("PO202608260002#3", hit.SourceObjectId);
@@ -316,7 +321,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 9, 17), new DateTime(2026, 8, 9), "OPEN", supplierName: "怡祥"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         Assert.Empty(hits);
     }
@@ -331,7 +336,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         Assert.Empty(hits);
     }
@@ -346,7 +351,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             Row("PO-CANCELLED", "1", new DateTime(2026, 8, 1), null, "CANCELLED"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         Assert.Empty(hits);
     }
@@ -358,7 +363,7 @@ public class S8PurchaseDeliveryProviderMappingTests
         var rowSet = RowSet(Row("PO-PARTIAL", "1", new DateTime(2026, 8, 1), null, "PARTIAL"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         Assert.Single(hits);
     }
@@ -375,9 +380,9 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
 
         var hitsBefore = S8TimeoutRuleEvaluator.EvaluateRows(
-            before, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            before, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
         var hitsAfter = S8TimeoutRuleEvaluator.EvaluateRows(
-            after, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            after, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         var dedupKey = Assert.Single(hitsBefore).DedupKey;
         Assert.Empty(hitsAfter);
@@ -391,7 +396,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
 
         var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5)));
+            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5)));
 
         Assert.Equal("PURCHASE_DELIVERY_ABNORMAL", hit.ExceptionTypeCode);
         Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);

+ 339 - 0
server/Admin.NET.Test/S8/S8RuleDefinitionContractTests.cs

@@ -0,0 +1,339 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.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 Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// S8-RULE-GOVERNANCE-BATCH1:规则定义的**运行时权威性**契约。
+///
+/// <para><b>本文件要证明的不是"我们加了几个 Definition 类"</b>,而是一件具体的事:
+/// 规则的业务语义已经不再由数据库和 params_json 决定。
+/// 判据是 T5 —— 把库里的 params_json 写成一份恶意的旧格式 JSON
+/// (换判定字段、换已完成状态、换去重身份、换异常类型),判定结果必须**逐字不变**。</para>
+///
+/// <para>不接 DB、不接 DI:Catalog 只依赖定义源,判定核心是 internal static。</para>
+/// </summary>
+public class S8RuleDefinitionContractTests
+{
+    private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
+
+    private static IS8RuleCatalog Catalog() =>
+        new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() });
+
+    /// <summary>一条**语义全错**的历史 params_json:每个 A 类字段都被写成足以改变判定结果的值。</summary>
+    private const string HostileLegacyParamsJson = """
+    {
+      "dueAtField": "evil_field",
+      "statusField": "evil_status",
+      "objectIdField": "evil_id",
+      "objectCodeField": "evil_code",
+      "completedStates": ["DELAYED"],
+      "exceptionTypeCode": "EVIL_TYPE",
+      "graceMinutes": 0
+    }
+    """;
+
+    private static AdoS8WatchRule Rule(string? paramsJson = null) => new()
+    {
+        Id = 1329909460023L,
+        TenantId = 838257186181189L,
+        FactoryId = 838257186320453L,
+        RuleCode = Rule01,
+        // 投影列刻意写成**错误值**:运行语义若真的来自代码定义,这些值就不该产生任何影响。
+        RuleType = "OUT_OF_RANGE",
+        DatasetCode = "SOMETHING_ELSE",
+        SourceObjectType = "EVIL_OBJECT_TYPE",
+        WatchObjectType = "EVIL_WATCH_TYPE",
+        SceneCode = "S1",
+        Severity = "FOLLOW",
+        PollIntervalSeconds = 300,
+        TriggerCountRequired = 1,
+        RecoverCountRequired = 2,
+        ParamsJson = paramsJson
+    };
+
+    /// <summary>
+    /// 一行已超期的采购订单行。
+    /// 刻意同时带上恶意 JSON 指向的四个列,且让它们指向**完全不同**的值:
+    /// 若判定真的被 JSON 牵着走,结果会明显不同而不是碰巧一致。
+    /// </summary>
+    private static S8MonitoringRowSet OverdueRow(string status = "DELAYED") =>
+        S8MonitoringRowSet.FromRows(new[]
+        {
+            S8MonitoringRow.FromValues(new Dictionary<string, object?>
+            {
+                [S8CanonicalColumns.SourceObjectId] = "UATA-PO-001#1",
+                [S8CanonicalColumns.RelatedObjectCode] = "UATA-PO-001",
+                [S8CanonicalColumns.DueAt] = new DateTime(2026, 8, 31, 4, 33, 7),
+                [S8CanonicalColumns.Status] = status,
+                ["evil_field"] = new DateTime(2030, 1, 1),   // 未来时间:若被当作 due_at 就不会命中
+                ["evil_status"] = "WHATEVER",
+                ["evil_id"] = "EVIL-ID",
+                ["evil_code"] = "EVIL-CODE"
+            })
+        });
+
+    // ───────────────────────── T1:Rule 01 定义 ─────────────────────────
+
+    [Fact]
+    public void T1_Catalog_Defines_Rule01_WithPinnedContract()
+    {
+        var d = Catalog().GetRequired(Rule01);
+
+        Assert.Equal(S8BusinessDatasetDefinitions.PurchaseDeliveryCode, d.DatasetCode);
+        Assert.Equal(S8TimeoutRuleEvaluator.RuleTypeCode, d.RuleType);
+        Assert.Equal("DATE", d.RuleMechanism);
+        Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, d.SourceObjectType);
+        Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryExceptionTypeCode, d.ExceptionTypeCode);
+        Assert.Equal("S4", d.SceneCode);
+        Assert.Equal("S4", d.StageCode);
+        Assert.Equal("MATERIAL_PURCHASE", d.OrderFlowCode);
+
+        // 配置页的「规则名称 / 业务说明」在 DB 里没有对应列,只能来自定义。
+        Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
+        Assert.False(string.IsNullOrWhiteSpace(d.Description));
+    }
+
+    [Fact]
+    public void T1_Rule01_CompletedStates_ArePinned()
+    {
+        var timeout = Catalog().GetRequired(Rule01).Timeout;
+        Assert.NotNull(timeout);
+
+        // 三值取自 status 域取证(三个写入方合并)。少一个就会把已完成单判成延误。
+        Assert.Equal(new[] { "COMPLETED", "CLOSED", "CANCELLED" }, timeout!.CompletedStates);
+    }
+
+    [Fact]
+    public void T1_Rule01_JudgementColumns_AreCanonical()
+    {
+        var t = Catalog().GetRequired(Rule01).Timeout!;
+        Assert.Equal(S8CanonicalColumns.DueAt, t.DueAtColumn);
+        Assert.Equal(S8CanonicalColumns.Status, t.StatusColumn);
+        Assert.Equal(S8CanonicalColumns.SourceObjectId, t.SourceObjectIdColumn);
+        Assert.Equal(S8CanonicalColumns.RelatedObjectCode, t.RelatedObjectCodeColumn);
+    }
+
+    // ───────────────────────── T2:重复定义 ─────────────────────────
+
+    [Fact]
+    public void T2_DuplicateRuleCode_FailsCatalogConstruction()
+    {
+        var ex = Assert.Throws<InvalidOperationException>(() =>
+            new S8RuleCatalog(new IS8RuleDefinitionSource[]
+            {
+                new S8PurchaseDeliveryRuleDefinitions(),
+                new S8PurchaseDeliveryRuleDefinitions()
+            }));
+        Assert.Contains(Rule01, ex.Message);
+    }
+
+    [Theory]
+    [InlineData("NOT_A_TYPE", "rule_type")]
+    [InlineData("timeout", "rule_type")]   // 大小写:调度器 switch 是 ordinal 敏感的
+    public void T2_InvalidRuleType_FailsCatalogConstruction(string ruleType, string _)
+    {
+        var ex = Assert.Throws<InvalidOperationException>(() =>
+            new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { RuleType = ruleType } }));
+        Assert.Contains("rule_type", ex.Message);
+    }
+
+    [Fact]
+    public void T2_TimeoutWithoutSemantics_FailsCatalogConstruction()
+    {
+        var ex = Assert.Throws<InvalidOperationException>(() =>
+            new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { DropTimeout = true } }));
+        Assert.Contains("TIMEOUT 判定语义", ex.Message);
+    }
+
+    [Fact]
+    public void T2_InvalidScene_FailsCatalogConstruction()
+    {
+        var ex = Assert.Throws<InvalidOperationException>(() =>
+            new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { SceneCode = "S2S6_PRODUCTION" } }));
+        Assert.Contains("scene_code", ex.Message);
+    }
+
+    // ───────────────────────── T3:无定义规则 ─────────────────────────
+
+    [Fact]
+    public void T3_UnknownRuleCode_IsRejectedWithDedicatedReason()
+    {
+        var catalog = Catalog();
+        Assert.Null(catalog.TryGet("RULE_THAT_A_USER_INVENTED"));
+        Assert.False(catalog.IsDefined("RULE_THAT_A_USER_INVENTED"));
+
+        var ex = Assert.Throws<S8BizException>(() => catalog.GetRequired("RULE_THAT_A_USER_INVENTED"));
+
+        // 必须是专属 reason,不能是 rule_not_configured / UnsupportedRule / NullReference。
+        Assert.Contains(S8RuleCatalog.ReasonNotFound, ex.Message);
+        Assert.DoesNotContain("rule_not_configured", ex.Message);
+    }
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("   ")]
+    public void T3_BlankRuleCode_IsRejected(string? ruleCode)
+    {
+        Assert.Throws<S8BizException>(() => Catalog().GetRequired(ruleCode));
+    }
+
+    // ───────────────────────── T4:params_json = NULL ─────────────────────────
+
+    [Fact]
+    public void T4_NullParamsJson_StillProducesValidRuntimeParameters()
+    {
+        // 这正是本地 Rule 01 当前的真实状态:一次 PUT /params {"enabled":false} 把它抹成了 NULL。
+        var effective = S8EffectiveRule.Resolve(Rule(paramsJson: null), Catalog().GetRequired(Rule01));
+
+        Assert.Equal(0, effective.Parameters.GraceMinutes);          // 回落定义默认值
+        Assert.Equal(300, effective.Parameters.PollIntervalSeconds); // 来自实体列
+        Assert.Equal(2, effective.Parameters.RecoverCountRequired);
+        Assert.Null(effective.Parameters.DefaultOccurrenceDeptId);
+    }
+
+    [Fact]
+    public void T4_NullParamsJson_StillHits()
+    {
+        var effective = S8EffectiveRule.Resolve(Rule(paramsJson: null), Catalog().GetRequired(Rule01));
+
+        var hits = S8TimeoutRuleEvaluator.EvaluateRows(
+            OverdueRow(), effective, 838257186181189L, 838257186320453L, new DateTime(2026, 9, 6, 13, 4, 13));
+
+        var hit = Assert.Single(hits);
+        Assert.Equal("UATA-PO-001#1", hit.SourceObjectId);
+    }
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("not-a-json")]        // 历史脏数据不该把规则打死
+    [InlineData("[]")]                // 合法 JSON 但不是对象
+    public void T4_BrokenParamsJson_FallsBackToDefaults_WithoutThrowing(string? json)
+    {
+        var p = S8RuleRuntimeParameters.Resolve(Rule(json), Catalog().GetRequired(Rule01));
+        Assert.Equal(0, p.GraceMinutes);
+    }
+
+    // ───────────────────────── T5:定义权威(本文件的核心) ─────────────────────────
+
+    [Fact]
+    public void T5_HostileLegacyParamsJson_CannotRedefineJudgement()
+    {
+        var definition = Catalog().GetRequired(Rule01);
+        var clean = S8EffectiveRule.Resolve(Rule(paramsJson: null), definition);
+        var hostile = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), definition);
+
+        var detectedAt = new DateTime(2026, 9, 6, 13, 4, 13);
+        var cleanHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), clean, 1, 1, detectedAt);
+        var hostileHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), hostile, 1, 1, detectedAt);
+
+        // 恶意 JSON 把 completedStates 写成 ["DELAYED"] —— 若它还有话语权,这一行就不该命中。
+        Assert.Single(cleanHits);
+        Assert.Single(hostileHits);
+
+        Assert.Equal(cleanHits[0].SourceObjectId, hostileHits[0].SourceObjectId);
+        Assert.Equal(cleanHits[0].DedupKey, hostileHits[0].DedupKey);
+        Assert.Equal(cleanHits[0].ExceptionTypeCode, hostileHits[0].ExceptionTypeCode);
+        Assert.Equal(cleanHits[0].SourceObjectType, hostileHits[0].SourceObjectType);
+        Assert.Equal(cleanHits[0].SceneCode, hostileHits[0].SceneCode);
+    }
+
+    [Fact]
+    public void T5_JudgementIdentity_ComesFromDefinition_NotDbColumnsNorJson()
+    {
+        var effective = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01));
+
+        var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows(
+            OverdueRow(), effective, 838257186181189L, 838257186320453L, new DateTime(2026, 9, 6, 13, 4, 13)));
+
+        // 身份三件套全部来自定义:JSON 写的 EVIL-ID / EVIL_TYPE、DB 列写的 EVIL_OBJECT_TYPE 都不生效。
+        Assert.Equal("UATA-PO-001#1", hit.SourceObjectId);
+        Assert.Equal("UATA-PO-001", hit.RelatedObjectCode);
+        Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, hit.SourceObjectType);
+        Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryExceptionTypeCode, hit.ExceptionTypeCode);
+        Assert.Equal("S4", hit.SceneCode);
+        Assert.Equal(Rule01, hit.SourceRuleCode);
+
+        Assert.DoesNotContain("EVIL", hit.SourceObjectId);
+        Assert.DoesNotContain("EVIL", hit.DedupKey);
+        Assert.DoesNotContain("EVIL", hit.SourceObjectType);
+    }
+
+    [Fact]
+    public void T5_CompletedState_StillSkips_WhenDefinitionSaysSo()
+    {
+        // 反向对照:定义确实在起作用,而不是"恒命中所以看起来一样"。
+        var rowSet = OverdueRow(status: "COMPLETED");
+
+        var effective = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01));
+        Assert.Empty(S8TimeoutRuleEvaluator.EvaluateRows(
+            rowSet, effective, 1, 1, new DateTime(2026, 9, 6, 13, 4, 13)));
+    }
+
+    [Fact]
+    public void T5_LegacyGraceMinutes_IsStillHonoured_BecauseItIsARuntimeParameter()
+    {
+        // B 类字段必须**继续**生效 —— 否则就是把参数一起锁死了,那是矫枉过正。
+        var p = S8RuleRuntimeParameters.Resolve(Rule("""{"graceMinutes": 4321}"""), Catalog().GetRequired(Rule01));
+        Assert.Equal(4321, p.GraceMinutes);
+    }
+
+    [Fact]
+    public void T5_LegacyDepartmentDefaults_AreStillHonoured()
+    {
+        var p = S8RuleRuntimeParameters.Resolve(
+            Rule("""{"defaultOccurrenceDeptId": 245, "defaultResponsibleDeptId": 246}"""),
+            Catalog().GetRequired(Rule01));
+        Assert.Equal(245L, p.DefaultOccurrenceDeptId);
+        Assert.Equal(246L, p.DefaultResponsibleDeptId);
+    }
+
+    // ───────────────────────── 参数序列化:A 类不回写 ─────────────────────────
+
+    [Fact]
+    public void ToParamsJson_WritesOnlyRuntimeKeys_DroppingLegacyDefinitionFields()
+    {
+        var p = S8RuleRuntimeParameters.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01));
+        var json = p.ToParamsJson();
+
+        // 一次正常保存之后,旧 A 类字段自然消失——无需任何 migration。
+        foreach (var legacy in new[] { "dueAtField", "statusField", "objectIdField", "objectCodeField", "completedStates", "exceptionTypeCode" })
+            Assert.DoesNotContain(legacy, json, StringComparison.Ordinal);
+
+        Assert.Contains("graceMinutes", json, StringComparison.Ordinal);
+    }
+
+    /// <summary>用于构造"写坏了的定义",验证 Catalog 的 fail-fast。</summary>
+    private sealed class BadSource : IS8RuleDefinitionSource
+    {
+        public string RuleType { get; init; } = S8TimeoutRuleEvaluator.RuleTypeCode;
+        public string SceneCode { get; init; } = "S4";
+        public bool DropTimeout { get; init; }
+
+        public IEnumerable<S8RuleDefinition> GetDefinitions() => new[]
+        {
+            new S8RuleDefinition
+            {
+                RuleCode = "RULE_BAD_FOR_TEST",
+                DisplayName = "坏定义",
+                Description = "仅用于校验 Catalog 的 fail-fast",
+                DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode,
+                RuleType = RuleType,
+                RuleMechanism = "DATE",
+                SourceObjectType = "X",
+                SceneCode = SceneCode,
+                StageCode = "S4",
+                ExceptionTypeCode = "X",
+                Timeout = DropTimeout ? null : new S8TimeoutSemantics()
+            }
+        };
+    }
+}

+ 114 - 36
server/Admin.NET.Test/S8/S8TimeoutEvaluatorTests.cs

@@ -1,70 +1,148 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
-using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 using Xunit;
 
 namespace Admin.NET.Test.S8;
 
 /// <summary>
-/// R2-TIMEOUT-EVALUATOR 纯逻辑回归。覆盖 params_json 解析 + dedup_key 生成稳定性。
-/// 不接 DB / 不接 DI;evaluator 的 SQL 与 SqlSugarRepository 路径走 run-once 端到端验证。
+/// TIMEOUT evaluator 纯逻辑回归:运行参数解析 + dedup_key 生成稳定性。
+/// 不接 DB / 不接 DI。
+///
+/// <para><b>S8-RULE-GOVERNANCE-BATCH1 的变化</b>:本文件原先覆盖的是
+/// <c>S8TimeoutParams.Parse</c> —— 那个类同时解析判定语义(dueAtField / statusField /
+/// completedStates / objectIdField / exceptionTypeCode)与运行参数(graceMinutes)。
+/// 判定语义已迁入代码定义,对它的断言移至 <c>S8RuleDefinitionContractTests</c>;
+/// 本文件改为覆盖剩下的那一半:<see cref="S8RuleRuntimeParameters"/>。</para>
+///
+/// <para>dedup_key 相关断言**逐字保留** —— 那部分语义本批未改。</para>
 /// </summary>
 public class S8TimeoutEvaluatorTests
 {
+    private static S8RuleDefinition Rule01Definition() =>
+        new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
+            .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode);
+
+    private static AdoS8WatchRule Rule(string? paramsJson, string severity = "SERIOUS") => new()
+    {
+        RuleCode = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode,
+        Severity = severity,
+        PollIntervalSeconds = 300,
+        TriggerCountRequired = 1,
+        RecoverCountRequired = 2,
+        ParamsJson = paramsJson
+    };
+
+    // ───────────────── 运行参数解析(原 params 解析用例的继承者) ─────────────────
+
     [Fact]
-    public void TimeoutParams_Parses_FullSpec()
+    public void RuntimeParameters_Parse_FullRuntimeSpec()
     {
         var json = """
         {
-          "dueAtField": "due_at",
-          "statusField": "status",
-          "completedStates": ["CLOSED", "DONE", "COMPLETED"],
-          "objectCodeField": "related_object_code",
-          "objectIdField": "source_object_id",
           "graceMinutes": 5,
-          "exceptionTypeCode": "DELIVERY_DELAY"
+          "defaultOccurrenceDeptId": 245,
+          "defaultResponsibleDeptId": 246
         }
         """;
-        var p = S8TimeoutParams.Parse(json);
-        Assert.Equal("due_at", p.DueAtField);
-        Assert.Equal("status", p.StatusField);
-        Assert.Equal(new[] { "CLOSED", "DONE", "COMPLETED" }, p.CompletedStates);
-        Assert.Equal("related_object_code", p.ObjectCodeField);
-        Assert.Equal("source_object_id", p.ObjectIdField);
+        var p = S8RuleRuntimeParameters.Resolve(Rule(json), Rule01Definition());
+
         Assert.Equal(5, p.GraceMinutes);
-        Assert.Equal("DELIVERY_DELAY", p.ExceptionTypeCode);
+        Assert.Equal(245L, p.DefaultOccurrenceDeptId);
+        Assert.Equal(246L, p.DefaultResponsibleDeptId);
+        Assert.Equal(300, p.PollIntervalSeconds);
+        Assert.Equal(1, p.TriggerCountRequired);
+        Assert.Equal(2, p.RecoverCountRequired);
+        Assert.Equal(S8SeverityCode.Serious, p.Severity);
     }
 
     [Fact]
-    public void TimeoutParams_Tolerates_MissingOptionalFields()
+    public void RuntimeParameters_Tolerates_MissingOptionalFields()
     {
-        var json = """{ "dueAtField": "due_at", "statusField": "status", "exceptionTypeCode": "PENDING_SHIPMENT" }""";
-        var p = S8TimeoutParams.Parse(json);
-        Assert.Equal("due_at", p.DueAtField);
-        Assert.Equal("status", p.StatusField);
-        Assert.Empty(p.CompletedStates);
-        Assert.Null(p.ObjectCodeField);
-        Assert.Null(p.ObjectIdField);
-        Assert.Equal(0, p.GraceMinutes);
-        Assert.Equal("PENDING_SHIPMENT", p.ExceptionTypeCode);
+        var p = S8RuleRuntimeParameters.Resolve(Rule("""{ }"""), Rule01Definition());
+
+        Assert.Equal(0, p.GraceMinutes);   // 定义声明的默认值
+        Assert.Null(p.DefaultOccurrenceDeptId);
+        Assert.Null(p.DefaultResponsibleDeptId);
+    }
+
+    /// <summary>
+    /// 关键回归:旧 A 类字段读到也必须忽略。
+    /// 这是「用户不能通过 params_json 改判定口径」在参数解析这一层的守卫;
+    /// 判定结果层面的守卫在 <c>S8RuleDefinitionContractTests.T5_*</c>。
+    /// </summary>
+    [Fact]
+    public void RuntimeParameters_Ignores_LegacyDefinitionFields()
+    {
+        var json = """
+        {
+          "dueAtField": "evil_field",
+          "statusField": "evil_status",
+          "completedStates": ["OPEN"],
+          "objectIdField": "evil_id",
+          "exceptionTypeCode": "EVIL",
+          "graceMinutes": 7
+        }
+        """;
+        var p = S8RuleRuntimeParameters.Resolve(Rule(json), Rule01Definition());
+
+        // B 类照常生效
+        Assert.Equal(7, p.GraceMinutes);
+
+        // A 类不会以任何形式出现在回写的 params_json 里
+        var written = p.ToParamsJson();
+        Assert.DoesNotContain("evil", written, StringComparison.OrdinalIgnoreCase);
+        Assert.DoesNotContain("completedStates", written, StringComparison.Ordinal);
     }
 
     [Fact]
-    public void TimeoutParams_Skips_BlankCompletedStates()
+    public void RuntimeParameters_DoesNotThrow_OnInvalidJson()
     {
-        var json = """{ "completedStates": ["CLOSED", "  ", "", "DONE"] }""";
-        var p = S8TimeoutParams.Parse(json);
-        Assert.Equal(new[] { "CLOSED", "DONE" }, p.CompletedStates);
+        // 旧实现在这里抛 JsonException,由 evaluator 捕获后把整条规则标 params_parse_failed。
+        // 现在语义在代码里,一份读不懂的运行参数不该让规则停摆 —— 回落默认值即可。
+        var p = S8RuleRuntimeParameters.Resolve(Rule("not-a-json"), Rule01Definition());
+        Assert.Equal(0, p.GraceMinutes);
+    }
+
+    /// <summary>
+    /// severity 非法值必须回落**定义声明的默认值**,而不是被 <c>S8SeverityCode.Normalize</c>
+    /// 静默降级成 FOLLOW —— 库里 severity='HIGH' 的那一行当年正是被那条兜底吞进来的。
+    /// </summary>
+    [Theory]
+    [InlineData("HIGH")]
+    [InlineData("LOW")]
+    [InlineData("")]
+    [InlineData(null)]
+    public void RuntimeParameters_InvalidSeverity_FallsBackToDefinitionDefault(string? severity)
+    {
+        var p = S8RuleRuntimeParameters.Resolve(Rule("{}", severity!), Rule01Definition());
+        Assert.Equal(S8SeverityCode.Serious, p.Severity);   // Rule 01 的定义默认值
+    }
+
+    [Theory]
+    [InlineData(S8SeverityCode.Follow)]
+    [InlineData(S8SeverityCode.Serious)]
+    public void RuntimeParameters_ValidSeverity_IsHonoured(string severity)
+    {
+        var p = S8RuleRuntimeParameters.Resolve(Rule("{}", severity), Rule01Definition());
+        Assert.Equal(severity, p.Severity);
     }
 
     [Fact]
-    public void TimeoutParams_Throws_OnInvalidJson()
+    public void RuntimeParameters_OutOfRangeColumnValues_FallBackToDefaults()
     {
-        // evaluator 在 EvaluateAsync 内 try/catch 该异常并降级为空命中;本断言锁定异常类型契约。
-        // ThrowsAny 而非 Throws:System.Text.Json 实际抛 JsonReaderException(JsonException 子类),
-        // xunit v3 的 Assert.Throws<T> 是 strict 类型匹配,必须用 ThrowsAny 接受子类。
-        Assert.ThrowsAny<JsonException>(() => S8TimeoutParams.Parse("not-a-json"));
+        var rule = Rule("{}");
+        rule.PollIntervalSeconds = 5;        // < 60,越界
+        rule.TriggerCountRequired = 99;      // > 10,越界
+        var p = S8RuleRuntimeParameters.Resolve(rule, Rule01Definition());
+
+        Assert.Equal(300, p.PollIntervalSeconds);
+        Assert.Equal(1, p.TriggerCountRequired);
     }
 
+    // ───────────────── dedup_key(本批未改,断言逐字保留) ─────────────────
+
     [Fact]
     public void BuildDedupKey_IsStable_SameInputsSameOutput()
     {

+ 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.488</AssemblyVersion>
-    <FileVersion>1.0.488</FileVersion>
-    <Version>1.0.488</Version>
+    <AssemblyVersion>1.0.489</AssemblyVersion>
+    <FileVersion>1.0.489</FileVersion>
+    <Version>1.0.489</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 316 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleParameterContractTests.cs

@@ -0,0 +1,316 @@
+using System.Reflection;
+using Microsoft.AspNetCore.Mvc;
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-RULE-GOVERNANCE-BATCH1:运行参数写入契约 + <b>G1 回归</b>。
+///
+/// <para><b>G1 是什么</b>(本地实测,非推演):旧的 <c>PUT /{id}/params</c> 是"整块覆盖 +
+/// 缺字段即置 NULL"。本地 Rule 01 在 13:34:13 还在正常命中 28 条,
+/// 13:40 一次 <c>{"enabled":false}</c> 之后 <c>params_json</c> 变成 NULL —— 规则的全部业务配置消失,
+/// 而旧 evaluator 在 params_json 为空时抛 <c>rule_not_configured</c>,等于这条规则被一次关开关打死。</para>
+///
+/// <para>本文件用**行为测试**钉住修复:参数叠加是纯函数 <c>S8WatchRuleService.ApplyParameters</c>,
+/// 可以不接数据库逐字段断言"未提供 = 不变"。结构性约束(端点分离、Definition 不可写)
+/// 才用反射守卫。</para>
+/// </summary>
+public class S8RuleParameterContractTests
+{
+    private static S8RuleDefinition Rule01() =>
+        new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
+            .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode);
+
+    /// <summary>一条参数**齐全**的规则行,模拟 G1 发生前的真实状态。</summary>
+    private static AdoS8WatchRule FullyConfiguredRule() => new()
+    {
+        Id = 1329909460023L,
+        TenantId = 838257186181189L,
+        FactoryId = 838257186320453L,
+        RuleCode = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode,
+        RuleType = "TIMEOUT",
+        DatasetCode = "PURCHASE_DELIVERY",
+        SourceObjectType = "PURCHASE_ORDER_LINE",
+        SceneCode = "S4",
+        Severity = S8SeverityCode.Serious,
+        PollIntervalSeconds = 600,
+        TriggerCountRequired = 3,
+        RecoverCountRequired = 2,
+        Enabled = true,
+        ParamsJson = """{"graceMinutes":45,"defaultOccurrenceDeptId":245,"defaultResponsibleDeptId":246}"""
+    };
+
+    private static S8RuleRuntimeParameters Apply(S8RuleParametersPayload payload) =>
+        S8WatchRuleService.ApplyParameters(FullyConfiguredRule(), payload, Rule01());
+
+    // ───────────────────────── T6:参数白名单 · 单字段隔离 ─────────────────────────
+
+    [Fact]
+    public void T6_ChangingGraceMinutes_LeavesEveryOtherParameterUntouched()
+    {
+        var next = Apply(new S8RuleParametersPayload { GraceMinutes = 15 });
+
+        Assert.Equal(15, next.GraceMinutes);            // 改了的
+        Assert.Equal(600, next.PollIntervalSeconds);    // 没改的,逐个确认
+        Assert.Equal(3, next.TriggerCountRequired);
+        Assert.Equal(2, next.RecoverCountRequired);
+        Assert.Equal(S8SeverityCode.Serious, next.Severity);
+        Assert.Equal(245L, next.DefaultOccurrenceDeptId);
+        Assert.Equal(246L, next.DefaultResponsibleDeptId);
+    }
+
+    [Fact]
+    public void T6_EmptyPayload_ChangesNothing()
+    {
+        var before = S8RuleRuntimeParameters.Resolve(FullyConfiguredRule(), Rule01());
+        var after = Apply(new S8RuleParametersPayload());
+
+        Assert.Equal(before.PollIntervalSeconds, after.PollIntervalSeconds);
+        Assert.Equal(before.TriggerCountRequired, after.TriggerCountRequired);
+        Assert.Equal(before.RecoverCountRequired, after.RecoverCountRequired);
+        Assert.Equal(before.GraceMinutes, after.GraceMinutes);
+        Assert.Equal(before.Severity, after.Severity);
+        Assert.Equal(before.DefaultOccurrenceDeptId, after.DefaultOccurrenceDeptId);
+        Assert.Equal(before.DefaultResponsibleDeptId, after.DefaultResponsibleDeptId);
+    }
+
+    [Fact]
+    public void T6_ChangingSeverity_LeavesGraceAndScheduleUntouched()
+    {
+        var next = Apply(new S8RuleParametersPayload { Severity = S8SeverityCode.Follow });
+
+        Assert.Equal(S8SeverityCode.Follow, next.Severity);
+        Assert.Equal(45, next.GraceMinutes);
+        Assert.Equal(600, next.PollIntervalSeconds);
+    }
+
+    [Theory]
+    [InlineData(59)]       // < min
+    [InlineData(86401)]    // > max
+    public void T6_OutOfRangePollInterval_IsRejected(int value)
+    {
+        var ex = Assert.Throws<S8BizException>(() =>
+            Apply(new S8RuleParametersPayload { PollIntervalSeconds = value }));
+        Assert.Contains("轮询间隔", ex.Message);
+    }
+
+    [Theory]
+    [InlineData(0)]
+    [InlineData(11)]
+    public void T6_OutOfRangeAntiflapCounts_AreRejected(int value)
+    {
+        Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { TriggerCountRequired = value }));
+        Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { RecoverCountRequired = value }));
+    }
+
+    [Fact]
+    public void T6_NegativeGraceMinutes_IsRejected()
+    {
+        Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { GraceMinutes = -1 }));
+    }
+
+    /// <summary>
+    /// severity 越权值必须**报错**,不能像 <c>S8SeverityCode.Normalize</c> 那样静默降级成 FOLLOW。
+    /// 库里 severity='HIGH' 的那一行当年正是被静默降级放进来的。
+    /// </summary>
+    [Theory]
+    [InlineData("HIGH")]
+    [InlineData("CRITICAL")]
+    [InlineData("bogus")]
+    public void T6_NonWhitelistedSeverity_IsRejected(string severity)
+    {
+        var ex = Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { Severity = severity }));
+        Assert.Contains(severity, ex.Message);
+    }
+
+    // ───────────────────────── T7:G1 —— 启停不得破坏参数 ─────────────────────────
+
+    /// <summary>
+    /// G1 的结构性修复:<c>enabled</c> 已经**不在**参数载荷里,因此"关开关顺手清空配置"
+    /// 这条路径在类型层面就不存在了。
+    /// </summary>
+    [Fact]
+    public void T7_ParametersPayload_HasNoEnabledWriteCapability()
+    {
+        var payload = new S8RuleParametersPayload { Enabled = false };
+
+        // Enabled 属性仍然存在,但它是**毒丸**:出现即拒绝,绝不静默忽略。
+        Assert.Contains("enabled", payload.RejectedDefinitionFields);
+
+        var ex = Assert.Throws<S8BizException>(() => Apply(payload));
+        Assert.Contains("/enable", ex.Message);
+    }
+
+    /// <summary>
+    /// G1 正面回归:旧写法 <c>{"enabled":false}</c> 现在既不会成功、更不会清空 params_json。
+    /// 与之对照,一次合法的参数更新必须**完整保留**其余全部参数。
+    /// </summary>
+    [Fact]
+    public void T7_LegacyDisablePayload_CannotWipeParamsJson()
+    {
+        var rule = FullyConfiguredRule();
+        var before = S8RuleRuntimeParameters.Resolve(rule, Rule01());
+
+        Assert.Throws<S8BizException>(() =>
+            S8WatchRuleService.ApplyParameters(rule, new S8RuleParametersPayload { Enabled = false }, Rule01()));
+
+        // 拒绝路径不得产生任何副作用:实体上的 params_json 逐字未变。
+        Assert.Equal(
+            """{"graceMinutes":45,"defaultOccurrenceDeptId":245,"defaultResponsibleDeptId":246}""",
+            rule.ParamsJson);
+
+        // 且合法路径写回的 params_json 语义等价(键顺序可不同,值必须一致)。
+        var after = S8WatchRuleService.ApplyParameters(rule, new S8RuleParametersPayload(), Rule01());
+        Assert.Equal(before.GraceMinutes, after.GraceMinutes);
+        Assert.Equal(before.DefaultOccurrenceDeptId, after.DefaultOccurrenceDeptId);
+        Assert.Equal(before.DefaultResponsibleDeptId, after.DefaultResponsibleDeptId);
+    }
+
+    // ───────────────────────── T8 / T9:启停端点独立且幂等 ─────────────────────────
+
+    [Fact]
+    public void T8_EnableAndDisable_AreDedicatedEndpoints_SeparateFromParams()
+    {
+        var controller = typeof(AdoS8ConfigWatchRulesController);
+
+        var enable = Assert.Single(Actions(controller, "EnableAsync"));
+        var disable = Assert.Single(Actions(controller, "DisableAsync"));
+
+        Assert.Equal("{id:long}/enable", Assert.Single(enable.GetCustomAttributes<HttpPostAttribute>()).Template);
+        Assert.Equal("{id:long}/disable", Assert.Single(disable.GetCustomAttributes<HttpPostAttribute>()).Template);
+
+        // 两者都不接 body:启停没有任何可配置项,接 body 就会重新打开"顺手改点别的"的口子。
+        Assert.Empty(enable.GetParameters().Where(p => p.ParameterType != typeof(long)));
+        Assert.Empty(disable.GetParameters().Where(p => p.ParameterType != typeof(long)));
+
+        foreach (var action in new[] { enable, disable })
+            Assert.NotEmpty(action.GetCustomAttributes()
+                .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal)));
+    }
+
+    [Fact]
+    public void T8_ParamsEndpoint_TakesOnlyTheNarrowParametersPayload()
+    {
+        var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "UpdateParamsAsync"));
+        var body = Assert.Single(action.GetParameters().Where(p => p.ParameterType != typeof(long)));
+        Assert.Equal(typeof(S8RuleParametersPayload), body.ParameterType);
+    }
+
+    [Fact]
+    public void T9_ServiceExposes_EnableAndDisable_WithTrustedScope()
+    {
+        var t = typeof(S8WatchRuleService);
+        Assert.NotNull(t.GetMethod("EnableAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
+        Assert.NotNull(t.GetMethod("DisableAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
+
+        // 旧的整块参数入口必须消失,否则 G1 的路径还在。
+        Assert.Null(t.GetMethods().FirstOrDefault(m => m.Name == "UpdateParamsAsync"));
+        Assert.NotNull(t.GetMethod("UpdateParametersAsync",
+            new[] { typeof(long), typeof(S8RuleParametersPayload), typeof(S8TrustedScope) }));
+    }
+
+    // ───────────────────────── T10:Definition 字段一律不可写 ─────────────────────────
+
+    [Theory]
+    [InlineData("datasetCode")]
+    [InlineData("ruleType")]
+    [InlineData("sourceObjectType")]
+    [InlineData("exceptionTypeCode")]
+    [InlineData("dueAtField")]
+    [InlineData("statusField")]
+    [InlineData("objectIdField")]
+    [InlineData("objectCodeField")]
+    [InlineData("completedStates")]
+    [InlineData("sceneCode")]
+    [InlineData("stageCode")]
+    [InlineData("orderFlowCode")]
+    [InlineData("ruleMechanism")]
+    [InlineData("paramsJson")]
+    public void T10_DefinitionFields_AreRejected_NotSilentlyIgnored(string field)
+    {
+        var payload = new S8RuleParametersPayload();
+        switch (field)
+        {
+            case "datasetCode": payload.DatasetCode = "X"; break;
+            case "ruleType": payload.RuleType = "X"; break;
+            case "sourceObjectType": payload.SourceObjectType = "X"; break;
+            case "exceptionTypeCode": payload.ExceptionTypeCode = "X"; break;
+            case "dueAtField": payload.DueAtField = "X"; break;
+            case "statusField": payload.StatusField = "X"; break;
+            case "objectIdField": payload.ObjectIdField = "X"; break;
+            case "objectCodeField": payload.ObjectCodeField = "X"; break;
+            case "completedStates": payload.CompletedStates = new[] { "X" }; break;
+            case "sceneCode": payload.SceneCode = "X"; break;
+            case "stageCode": payload.StageCode = "X"; break;
+            case "orderFlowCode": payload.OrderFlowCode = "X"; break;
+            case "ruleMechanism": payload.RuleMechanism = "X"; break;
+            case "paramsJson": payload.ParamsJson = "{}"; break;
+        }
+
+        Assert.Contains(field, payload.RejectedDefinitionFields);
+
+        var ex = Assert.Throws<S8BizException>(() => Apply(payload));
+        Assert.Contains(field, ex.Message);
+    }
+
+    [Fact]
+    public void T10_CleanPayload_ReportsNoRejectedFields()
+    {
+        // 反向对照:拒绝逻辑不是"全部拒绝"。
+        var payload = new S8RuleParametersPayload
+        {
+            PollIntervalSeconds = 300,
+            TriggerCountRequired = 1,
+            RecoverCountRequired = 2,
+            GraceMinutes = 0,
+            Severity = S8SeverityCode.Serious,
+            DefaultOccurrenceDeptId = 1,
+            DefaultResponsibleDeptId = 2
+        };
+        Assert.Empty(payload.RejectedDefinitionFields);
+
+        var next = Apply(payload);
+        Assert.Equal(300, next.PollIntervalSeconds);
+        Assert.Equal(0, next.GraceMinutes);
+    }
+
+    /// <summary>
+    /// 白名单是**闭集**:新增可写属性必须是有意识的决定。
+    /// 若有人往载荷上加一个新的可写字段却忘了想清楚它是 A 类还是 B 类,本用例会失败。
+    /// </summary>
+    [Fact]
+    public void T10_PayloadShape_IsPinned()
+    {
+        var writable = typeof(S8RuleParametersPayload)
+            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+            .Where(p => p.CanWrite)
+            .Select(p => p.Name)
+            .OrderBy(n => n, StringComparer.Ordinal)
+            .ToArray();
+
+        var expected = new[]
+        {
+            // 白名单(B 类)
+            "DefaultOccurrenceDeptId", "DefaultResponsibleDeptId", "GraceMinutes",
+            "PollIntervalSeconds", "RecoverCountRequired", "Severity", "TriggerCountRequired",
+            // 毒丸(A 类 + 已迁走的 enabled):存在只为显式报错
+            "CompletedStates", "DatasetCode", "DueAtField", "Enabled", "ExceptionTypeCode",
+            "ObjectCodeField", "ObjectIdField", "OrderFlowCode", "ParamsJson", "RuleMechanism",
+            "RuleType", "SceneCode", "SourceObjectType", "StageCode", "StatusField"
+        }.OrderBy(n => n, StringComparer.Ordinal).ToArray();
+
+        Assert.Equal(expected, writable);
+    }
+
+    private static IEnumerable<MethodInfo> Actions(Type controller, string name) =>
+        controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+            .Where(m => m.Name == name);
+}

+ 6 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRuleWriteGuardTests.cs

@@ -76,7 +76,12 @@ public class S8WatchRuleWriteGuardTests
     {
         var t = typeof(S8WatchRuleService);
         Assert.NotNull(t.GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
-        Assert.NotNull(t.GetMethod("UpdateParamsAsync", new[] { typeof(long), typeof(S8WatchRuleParamsPayload), typeof(S8TrustedScope) }));
+        // S8-RULE-GOVERNANCE-BATCH1:整块 params 入口已被强类型白名单入口取代(G1 修复)。
+        // 断言旧签名**不存在**,否则那条"缺字段即置 NULL"的路径就还活着。
+        Assert.Null(t.GetMethods().FirstOrDefault(m => m.Name == "UpdateParamsAsync"));
+        Assert.NotNull(t.GetMethod("UpdateParametersAsync", new[] { typeof(long), typeof(S8RuleParametersPayload), typeof(S8TrustedScope) }));
+        Assert.NotNull(t.GetMethod("EnableAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
+        Assert.NotNull(t.GetMethod("DisableAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
         Assert.NotNull(t.GetMethod("UpdateScheduleAsync", new[] { typeof(long), typeof(S8WatchRuleSchedulePayload), typeof(S8TrustedScope) }));
         Assert.NotNull(t.GetMethod("PauseAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
         Assert.NotNull(t.GetMethod("ResumeAsync", new[] { typeof(long), typeof(S8TrustedScope) }));

+ 45 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs

@@ -150,22 +150,61 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     }
 
     /// <summary>
-    /// R4 安全更新:仅修改 params_json 与 enabled,绝不接受 expression / rule_code / data_source_id /
-    /// scene_code / watch_object_type / rule_type / source_object_type 等敏感字段。
-    /// 服务端按 rule_type 用对应 evaluator 的 Params.Parse 进行 schema 校验。
+    /// S8-RULE-GOVERNANCE-BATCH1:运行参数的**部分更新**。
+    ///
+    /// <para>只接受该规则的代码定义所声明的运行参数白名单
+    /// (轮询间隔 / 抗抖次数 / 宽限分钟 / 严重度 / 部门兜底),未提供的字段保持原值。</para>
+    ///
+    /// <para><b>不再接受</b> params_json 原文与任何判定语义字段 —— 那些由代码定义,
+    /// 出现在载荷里会得到 400 而不是被静默忽略。<b>也不再接受 enabled</b>,
+    /// 启停请用 <c>/enable</c> 与 <c>/disable</c>。</para>
+    ///
+    /// <para>路由暂时保留 <c>/params</c> 以免与前端一次性大改耦合;
+    /// 前端适配排在 Batch 4。</para>
     /// </summary>
     [HttpPut("{id:long}/params")]
     [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> UpdateParamsAsync(long id, [FromBody] S8WatchRuleParamsPayload body)
+    public async Task<IActionResult> UpdateParamsAsync(long id, [FromBody] S8RuleParametersPayload body)
+    {
+        try { return Ok(await _svc.UpdateParametersAsync(id, body, await _scope.ResolveAsync())); }
+        catch (S8NotFoundException) { return NotFound(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>
+    /// 启用规则。<b>与参数写入完全分离</b>:本端点只写 enabled 与下次执行时间,
+    /// 不触碰 params_json、不触碰任何参数列、不触碰定义投影列。
+    ///
+    /// <para>这条分离是 G1 的结构性修复:此前启停与 params_json 共用一个写入口,
+    /// 一次「只想关个开关」的调用就会把规则的全部业务配置抹成 NULL。</para>
+    ///
+    /// <para>幂等:重复启用不产生写入。</para>
+    /// </summary>
+    [HttpPost("{id:long}/enable")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
+    public async Task<IActionResult> EnableAsync(long id)
+    {
+        try { return Ok(await _svc.EnableAsync(id, await _scope.ResolveAsync())); }
+        catch (S8NotFoundException) { return NotFound(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>
+    /// 停用规则。只写 enabled;幂等。
+    /// <b>刻意不过 Enable Gate</b>:数据集出问题之后仍然必须能把规则关掉。
+    /// </summary>
+    [HttpPost("{id:long}/disable")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
+    public async Task<IActionResult> DisableAsync(long id)
     {
-        try { return Ok(await _svc.UpdateParamsAsync(id, body, await _scope.ResolveAsync())); }
+        try { return Ok(await _svc.DisableAsync(id, await _scope.ResolveAsync())); }
         catch (S8NotFoundException) { return NotFound(); }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
     /// <summary>
     /// S8-SCHED-FRONTEND-1:调度参数安全更新(仅 poll_interval_seconds / trigger_count_required / recover_count_required)。
-    /// 不动 params_json / rule_type / expression / data_source_id / scene_code。
+    /// S8-RULE-GOVERNANCE-BATCH1:已委托到统一的参数写入路径,语义与 <c>/params</c> 一致
     /// </summary>
     [HttpPut("{id:long}/schedule")]
     [S8Permission(S8PermissionCatalog.ConfigWatchRule)]

+ 32 - 17
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs

@@ -51,21 +51,32 @@ public class S8DatasetEnableGate : ITransient
         _registry = registry;
     }
 
-    /// <summary>检查规则是否具备启用条件。</summary>
-    public S8DatasetEnableCheck Check(AdoS8WatchRule rule, long tenantId, long factoryId)
+    /// <summary>
+    /// 按数据库行检查。<b>S8-RULE-GOVERNANCE-BATCH1 起,正式启用路径不再走这个重载</b>——
+    /// 它读的是 <c>rule.DatasetCode</c> / <c>rule.RuleType</c>,而这两列现在只是
+    /// provisioning 维护的投影,可能与代码定义不一致。保留是为了既有校验路径与测试不受影响。
+    /// </summary>
+    public S8DatasetEnableCheck Check(AdoS8WatchRule rule, long tenantId, long factoryId) =>
+        Check(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId, factoryId);
+
+    /// <summary>
+    /// 按**显式的数据集编码与规则类型**检查。启用路径应使用本重载并传入代码定义的值,
+    /// 这样「能不能启用」的判定就不受任何人手工改 DB 投影列的影响。
+    /// </summary>
+    public S8DatasetEnableCheck Check(string? datasetCode, string? ruleType, string? ruleCode, long tenantId, long factoryId)
     {
         // ① dataset_code
-        if (string.IsNullOrWhiteSpace(rule.DatasetCode))
+        if (string.IsNullOrWhiteSpace(datasetCode))
             return S8DatasetEnableCheck.Fail(
                 S8DatasetReasonCode.CodeMissing,
                 "STANDARD_DATASET 模式的规则必须提供 dataset_code");
 
         // ② Catalog 定义
-        var definition = _catalog.Find(rule.DatasetCode);
+        var definition = _catalog.Find(datasetCode);
         if (definition == null)
             return S8DatasetEnableCheck.Fail(
                 S8DatasetReasonCode.NotDefined,
-                $"数据集 {rule.DatasetCode} 未在目录中定义");
+                $"数据集 {datasetCode} 未在目录中定义");
 
         // ③ 定义是否停用
         if (!definition.IsActive)
@@ -74,23 +85,23 @@ public class S8DatasetEnableGate : ITransient
                 $"数据集 {definition.DatasetCode} 已停用,不能用于规则执行");
 
         // ④ 规则类型已知
-        if (!S8RuleTypeFieldRequirements.IsKnown(rule.RuleType))
+        if (!S8RuleTypeFieldRequirements.IsKnown(ruleType))
             return S8DatasetEnableCheck.Fail(
                 S8DatasetReasonCode.UnknownRuleType,
-                $"规则类型 {rule.RuleType} 不在 canonical 词表内,无法校验数据集能力");
+                $"规则类型 {ruleType} 不在 canonical 词表内,无法校验数据集能力");
 
         // ⑤ 目录机制能力(定义层问题,先于 Provider 报出)
-        if (!definition.Capabilities.Supports(rule.RuleType))
+        if (!definition.Capabilities.Supports(ruleType))
             return S8DatasetEnableCheck.Fail(
                 S8DatasetReasonCode.CapabilityMismatch,
-                $"数据集 {definition.DatasetCode} 不支持 {rule.RuleType} 类规则");
+                $"数据集 {definition.DatasetCode} 不支持 {ruleType} 类规则");
 
         // ⑥ 目录 canonical 字段齐备(防"声明支持某机制但拿不出必需字段"的自相矛盾配置)
-        var missing = definition.Capabilities.MissingFieldsFor(rule.RuleType);
+        var missing = definition.Capabilities.MissingFieldsFor(ruleType);
         if (missing is { Count: > 0 })
             return S8DatasetEnableCheck.Fail(
                 S8DatasetReasonCode.MissingRequiredField,
-                $"数据集 {definition.DatasetCode} 缺少 {rule.RuleType} 必需字段:{string.Join(", ", missing)}");
+                $"数据集 {definition.DatasetCode} 缺少 {ruleType} 必需字段:{string.Join(", ", missing)}");
 
         // ⑦ Provider 注册
         if (!_registry.IsRegistered(definition.DatasetCode))
@@ -106,16 +117,16 @@ public class S8DatasetEnableGate : ITransient
                 $"Provider 自报数据集编码 {provider.DatasetCode} 与目录定义 {definition.DatasetCode} 不一致");
 
         // ⑨ Provider 实际能力必须与目录声明同样满足(不一致时按更严格的一方拒绝)
-        if (!provider.Capabilities.Supports(rule.RuleType))
+        if (!provider.Capabilities.Supports(ruleType))
             return S8DatasetEnableCheck.Fail(
                 S8DatasetReasonCode.CapabilityMismatch,
-                $"Provider 实现不支持 {rule.RuleType} 类规则(数据集 {definition.DatasetCode})");
+                $"Provider 实现不支持 {ruleType} 类规则(数据集 {definition.DatasetCode})");
 
-        var providerMissing = provider.Capabilities.MissingFieldsFor(rule.RuleType);
+        var providerMissing = provider.Capabilities.MissingFieldsFor(ruleType);
         if (providerMissing is { Count: > 0 })
             return S8DatasetEnableCheck.Fail(
                 S8DatasetReasonCode.MissingRequiredField,
-                $"Provider 实现缺少 {rule.RuleType} 必需字段:{string.Join(", ", providerMissing)}");
+                $"Provider 实现缺少 {ruleType} 必需字段:{string.Join(", ", providerMissing)}");
 
         // ⑩ 作用域
         if (tenantId <= 0)
@@ -127,9 +138,13 @@ public class S8DatasetEnableGate : ITransient
     }
 
     /// <summary>检查并在失败时抛 <see cref="S8BizException"/>(配置类错误,API 层直接回给调用方)。</summary>
-    public void EnsureCanEnable(AdoS8WatchRule rule, long tenantId, long factoryId)
+    public void EnsureCanEnable(AdoS8WatchRule rule, long tenantId, long factoryId) =>
+        EnsureCanEnable(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId, factoryId);
+
+    /// <summary>按显式数据集编码与规则类型检查并在失败时抛 <see cref="S8BizException"/>。启用路径用这一个。</summary>
+    public void EnsureCanEnable(string? datasetCode, string? ruleType, string? ruleCode, long tenantId, long factoryId)
     {
-        var result = Check(rule, tenantId, factoryId);
+        var result = Check(datasetCode, ruleType, ruleCode, tenantId, factoryId);
         if (!result.Ok)
             throw new S8BizException($"[{result.ReasonCode}] {result.Message}");
     }

+ 13 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/IS8RuleDefinitionSource.cs

@@ -0,0 +1,13 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+
+/// <summary>
+/// 规则定义来源。新增一条正式规则 = 实现(或扩充)本接口并在 <c>Startup</c> 注册,
+/// 走代码评审与发布流程;**不存在任何页面入口**。
+///
+/// <para>与 <c>IS8DatasetDefinitionSource</c> 同形,故意保持同一注册与聚合方式:
+/// 仓内不该出现两套 Catalog 风格。</para>
+/// </summary>
+public interface IS8RuleDefinitionSource
+{
+    IEnumerable<S8RuleDefinition> GetDefinitions();
+}

+ 93 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8PurchaseDeliveryRuleDefinitions.cs

@@ -0,0 +1,93 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+
+/// <summary>
+/// S8 正式规则定义的集中登记处(当前仅 Rule 01)。
+///
+/// <para>集中一处而非每规则一个文件,理由与 <see cref="S8BusinessDatasetDefinitions"/> 相同:
+/// 重复 rule_code 在数组里人工一眼可查;新增规则只加一个元素,不必再改 <c>Startup.cs</c>。</para>
+/// </summary>
+public sealed class S8PurchaseDeliveryRuleDefinitions : IS8RuleDefinitionSource
+{
+    /// <summary>Rule 01 编码。全链身份,任何地方引用必须逐字用本常量,不写字面量。</summary>
+    public const string PurchaseDeliveryDateDelayCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY";
+
+    /// <summary>Rule 01 建单使用的异常类型。</summary>
+    public const string PurchaseDeliveryExceptionTypeCode = "PURCHASE_DELIVERY_ABNORMAL";
+
+    /// <summary>Rule 01 源对象类型。**参与 dedup_key**。</summary>
+    public const string PurchaseOrderLineObjectType = "PURCHASE_ORDER_LINE";
+
+    public IEnumerable<S8RuleDefinition> GetDefinitions() => new[]
+    {
+        new S8RuleDefinition
+        {
+            RuleCode = PurchaseDeliveryDateDelayCode,
+            DisplayName = "采购交期延误",
+            Description =
+                "采购订单行已过约定到期日仍未完成交付时提报异常。" +
+                "到期日取交付计划需求日期,缺失时回落采购订单到期日;" +
+                "已完成 / 已关闭 / 已取消的订单行不再判为延误。",
+
+            DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode,
+            RuleType = S8TimeoutRuleEvaluator.RuleTypeCode,
+            RuleMechanism = "DATE",
+            SourceObjectType = PurchaseOrderLineObjectType,
+
+            SceneCode = S8ModuleCode.S4,
+            StageCode = S8ModuleCode.S4,
+            OrderFlowCode = "MATERIAL_PURCHASE",
+
+            ExceptionTypeCode = PurchaseDeliveryExceptionTypeCode,
+
+            // ────────────────────────────────────────────────────────────────
+            // 判定语义。四个列名都是 canonical 名 —— Provider 直接以这些名字产出行
+            // (见 S8PurchaseDeliveryDataProvider 的 SELECT 别名契约)。
+            //
+            // due_at 的血缘已完成取证:Provider 的 COALESCE(need_date, due_date)
+            // 与数据中台自身的 DELAYED 判据
+            // COALESCE(交付计划 need_date, 采购订单 need_date, 采购订单 due_date) < now
+            // 展开后逐字等价(S3MdpSyncTransformService 的 dwd_supplier_delivery INSERT)。
+            // 实测佐证:UAT 租户最新快照 36 行,evaluator 命中 28 条,
+            // 与中台自行标记的 DELAYED 28 条完全一致。
+            //
+            // ⚠️ UNPROVEN_CONTRACT_SEMANTICS:以上只证明 S8 与中台口径一致,
+            // 并未证明 need_date 等同于与供应商约定的承诺交期。该问题需追至源系统
+            // PurOrdDetail.NeedDate 并由业务确认,在此之前 due_at 不得被当作交付合同依据。
+            // ────────────────────────────────────────────────────────────────
+            Timeout = new S8TimeoutSemantics
+            {
+                DueAtColumn = S8CanonicalColumns.DueAt,
+                StatusColumn = S8CanonicalColumns.Status,
+
+                // 去重身份 = po_no + '#' + po_line,由 Provider 拼好后放在 source_object_id 列。
+                // 真库证据:dwd_supplier_delivery 全表 39854 行 / 4 租户 / 97 个快照,
+                // (tenant_id, stat_date, po_no, po_line) 重复组为 0。
+                SourceObjectIdColumn = S8CanonicalColumns.SourceObjectId,
+                RelatedObjectCodeColumn = S8CanonicalColumns.RelatedObjectCode,
+
+                // status 域 = COMPLETED / DELAYED / OPEN / CLOSED / CANCELLED / PARTIAL
+                // (三个写入方合并取证:S3MdpSyncTransformService / DeliveryScheduleService /
+                //   ProcessOutsourceOrderService)。
+                // 下列三个视为已完成;DELAYED / OPEN / PARTIAL 均可命中。
+                CompletedStates = new[] { "COMPLETED", "CLOSED", "CANCELLED" }
+            },
+
+            Parameters = new S8RuleParameterPolicy
+            {
+                PollIntervalSecondsDefault = 300,
+                TriggerCountRequiredDefault = 1,
+
+                // 交期类异常抖动主要来自快照切换,连续两次未命中才判恢复。
+                RecoverCountRequiredDefault = 2,
+
+                GraceMinutesDefault = 0,
+                SeverityDefault = S8SeverityCode.Serious,
+                AllowsDepartmentDefaults = true
+            }
+        }
+    };
+}

+ 129 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8RuleCatalog.cs

@@ -0,0 +1,129 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+
+/// <summary>规则目录:回答「这个 rule_code 在当前代码版本里是否被定义、定义成什么」。</summary>
+public interface IS8RuleCatalog
+{
+    /// <summary>取定义;未定义返回 null。</summary>
+    S8RuleDefinition? TryGet(string? ruleCode);
+
+    /// <summary>取定义;未定义抛 <see cref="S8BizException"/>,消息带 reason code。</summary>
+    S8RuleDefinition GetRequired(string? ruleCode);
+
+    /// <summary>是否已定义。</summary>
+    bool IsDefined(string? ruleCode);
+
+    /// <summary>全部已定义规则。Batch 2 的 provisioning 将按此为每个租户落 runtime policy 行。</summary>
+    IReadOnlyCollection<S8RuleDefinition> Definitions { get; }
+}
+
+/// <summary>
+/// 默认目录实现:聚合所有 <see cref="IS8RuleDefinitionSource"/>,并在构造时做完整性校验。
+///
+/// <para><b>为什么校验放在构造函数而不是运行期</b>:一条定义写错(rule_type 不在词表、
+/// TIMEOUT 却没给 Timeout 语义、scene 不是 S1–S7)属于**发布事故**,
+/// 应该在服务起来的时候就炸掉,而不是等某条规则跑到那一 tick 才报错——
+/// 那时错误只出现在后台日志里,页面上一切正常。</para>
+///
+/// <para><b>注册为 Singleton + 启动期强制解析</b>(见 <c>Startup.Configure</c>)。
+/// 数据集侧 <c>S8DatasetCatalog</c> 注册为 Transient,其重复定义的失败形态是
+/// 「首次解析该服务的那个请求 500」而非启动崩溃 —— 该问题在
+/// <c>S8BusinessDatasetDefinitions</c> 的注释里已被记录为已知弱点。规则侧不重复这个弱点。</para>
+/// </summary>
+public class S8RuleCatalog : IS8RuleCatalog
+{
+    /// <summary>规则在当前代码版本中无定义。与「配置错了」严格区分:它意味着该规则不该运行。</summary>
+    public const string ReasonNotFound = "rule_definition_not_found";
+
+    private readonly Dictionary<string, S8RuleDefinition> _definitions;
+
+    public S8RuleCatalog(IEnumerable<IS8RuleDefinitionSource> sources)
+    {
+        // rule_code 在 DB 侧是大小写敏感比较(dedup_key 逐字拼接),
+        // 但目录查找用不敏感比较可以把「大小写写错」暴露成重复定义而不是静默两条,
+        // 与 S8DatasetCatalog 的 OrdinalIgnoreCase 一致。
+        _definitions = new Dictionary<string, S8RuleDefinition>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (var source in sources)
+        {
+            foreach (var definition in source.GetDefinitions())
+            {
+                Validate(definition, source.GetType().Name);
+
+                var code = definition.RuleCode.Trim();
+                if (_definitions.ContainsKey(code))
+                    throw new InvalidOperationException(
+                        $"监控规则 {code} 被重复定义(来源包含 {source.GetType().Name})");
+
+                _definitions[code] = definition;
+            }
+        }
+    }
+
+    public IReadOnlyCollection<S8RuleDefinition> Definitions => _definitions.Values;
+
+    public bool IsDefined(string? ruleCode) =>
+        !string.IsNullOrWhiteSpace(ruleCode) && _definitions.ContainsKey(ruleCode!.Trim());
+
+    public S8RuleDefinition? TryGet(string? ruleCode)
+    {
+        if (string.IsNullOrWhiteSpace(ruleCode)) return null;
+        return _definitions.TryGetValue(ruleCode.Trim(), out var definition) ? definition : null;
+    }
+
+    public S8RuleDefinition GetRequired(string? ruleCode) =>
+        TryGet(ruleCode) ?? throw new S8BizException(
+            $"[{ReasonNotFound}] 监控规则 {(string.IsNullOrWhiteSpace(ruleCode) ? "(空)" : ruleCode)} 在当前版本中没有代码定义,无法配置或运行");
+
+    /// <summary>
+    /// 定义完整性校验。每一条都对应一种「存得下但跑不通」的历史故障形态,不是形式检查。
+    /// </summary>
+    private static void Validate(S8RuleDefinition d, string sourceName)
+    {
+        void Fail(string message) =>
+            throw new InvalidOperationException($"监控规则定义非法(来源 {sourceName}):{message}");
+
+        if (string.IsNullOrWhiteSpace(d.RuleCode)) Fail("rule_code 不能为空");
+        if (string.IsNullOrWhiteSpace(d.DisplayName)) Fail($"{d.RuleCode} 缺少 DisplayName(配置页的规则名称没有其他来源)");
+        if (string.IsNullOrWhiteSpace(d.DatasetCode)) Fail($"{d.RuleCode} 缺少 dataset_code");
+        if (string.IsNullOrWhiteSpace(d.ExceptionTypeCode)) Fail($"{d.RuleCode} 缺少 exception_type_code");
+        if (string.IsNullOrWhiteSpace(d.SourceObjectType)) Fail($"{d.RuleCode} 缺少 source_object_type(它参与 dedup_key)");
+
+        // rule_type 必须在真实存在 evaluator 的词表内,且**大小写逐字相等**。
+        // 刻意不用 S8RuleTypeFieldRequirements.IsKnown —— 它内部做 ToUpperInvariant,
+        // 于是 "timeout" 能过校验;而调度器与 evaluator 的 switch 是 ordinal 敏感的,
+        // 那条定义会一路存下来、最后在运行期报 unsupported_rule_type。
+        // 「存得下但跑不通」正是本 Catalog 要消灭的形态。
+        if (!S8RuleTypeFieldRequirements.KnownRuleTypes.Contains(d.RuleType, StringComparer.Ordinal))
+            Fail($"{d.RuleCode} 的 rule_type 非法:{d.RuleType};当前仅支持 "
+                 + string.Join(" / ", S8RuleTypeFieldRequirements.KnownRuleTypes));
+
+        // scene / stage 必须是严格 S1–S7,拒 legacy 复合场景(与 CreateAsync 既有口径一致)。
+        if (!S8ModuleCode.IsValid(d.SceneCode))
+            Fail($"{d.RuleCode} 的 scene_code 非法:{d.SceneCode};当前仅支持 " + string.Join(" / ", S8ModuleCode.All));
+        if (!S8ModuleCode.IsValid(d.StageCode))
+            Fail($"{d.RuleCode} 的 stage_code 非法:{d.StageCode}");
+
+        // 类型与语义必须配套:声明 TIMEOUT 却不给 Timeout 语义,运行期只会表现为 NullReference。
+        switch (d.RuleType)
+        {
+            case var t when t == S8TimeoutRuleEvaluator.RuleTypeCode:
+                if (d.Timeout == null) Fail($"{d.RuleCode} 声明 TIMEOUT 但未提供 TIMEOUT 判定语义");
+                break;
+            case var t when t == S8ShortageRuleEvaluator.RuleTypeCode:
+                if (d.Shortage == null) Fail($"{d.RuleCode} 声明 SHORTAGE 但未提供 SHORTAGE 判定语义");
+                break;
+            case var t when t == S8OutOfRangeRuleEvaluator.RuleTypeCode:
+                if (d.OutOfRange == null) Fail($"{d.RuleCode} 声明 OUT_OF_RANGE 但未提供 OUT_OF_RANGE 判定语义");
+                break;
+        }
+
+        var policy = d.Parameters ?? new S8RuleParameterPolicy();
+        if (policy.AllowedSeverities == null || policy.AllowedSeverities.Count == 0)
+            Fail($"{d.RuleCode} 的 AllowedSeverities 不能为空");
+        if (!policy.AllowedSeverities!.Contains(policy.SeverityDefault, StringComparer.Ordinal))
+            Fail($"{d.RuleCode} 的默认严重度 {policy.SeverityDefault} 不在允许集合内");
+    }
+}

+ 184 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8RuleDefinition.cs

@@ -0,0 +1,184 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+
+/// <summary>
+/// S8-RULE-GOVERNANCE-BATCH1:监控规则的**代码定义**。
+///
+/// <para><b>它解决什么</b>:在此之前,一条规则的全部业务语义都存在 <c>ado_s8_watch_rule</c> 里——
+/// <c>rule_type</c> / <c>dataset_code</c> / <c>source_object_type</c> 是列,
+/// 而 <c>due_at</c> 取哪一列、哪些状态算已完成、去重身份是什么、建什么异常类型,
+/// 全部埋在 <c>params_json</c> 这个自由文本里。于是「改判定口径」与「改轮询间隔」
+/// 走的是同一个配置接口、同一份权限、同一次保存 —— 业务用户可以在页面上把
+/// <c>completedStates</c> 改成 <c>["OPEN"]</c>,规则立刻换一套业务含义,而没有任何评审。</para>
+///
+/// <para><b>本类的定位</b>:规则的业务语义从此**只存在于代码里**,随 git 走,随 release 发。
+/// 数据库上那几个同名列降级为 <b>provisioning 维护的只读投影</b>:它们仍然被 SQL 谓词与既有索引使用
+/// (如 <c>PickReadyRulesAsync</c> 的 <c>dataset_code != ''</c>),但**不再是运行时语义的真源**。
+/// 有人手工改了那几个列,规则的行为不变。</para>
+///
+/// <para><b>为什么镜像 <see cref="S8DatasetDefinition"/> 而不是另造一套</b>:
+/// 数据集侧早已是「代码声明定义 + Catalog 聚合 + 运行期按声明校验」,
+/// 规则侧是同一个问题的同一种解法。仓内不应出现两种风格的 Catalog。</para>
+///
+/// <para><b>刻意不做的事</b>:不引入 DSL、不引入表达式、不引入
+/// <c>Dictionary&lt;string, object&gt;</c>。定义是强类型的 —— 它要能被编译器和单元测试检查,
+/// 而不是被"运行到那一行才知道配错了"检查。</para>
+/// </summary>
+public sealed class S8RuleDefinition
+{
+    /// <summary>规则人读编码。全链身份:dedup_key / detection_log.rule_code / exception.source_rule_code 都引用它。</summary>
+    public string RuleCode { get; init; } = string.Empty;
+
+    /// <summary>业务名称。<c>ado_s8_watch_rule</c> 无此列,配置页的「规则名称」只能来自这里。</summary>
+    public string DisplayName { get; init; } = string.Empty;
+
+    /// <summary>业务说明。同上,配置页「业务说明」的唯一来源。面向业务读者,不写表名 / 列名 / SQL。</summary>
+    public string Description { get; init; } = string.Empty;
+
+    /// <summary>取数数据集编码。必须在 <see cref="IS8DatasetCatalog"/> 中已定义。</summary>
+    public string DatasetCode { get; init; } = string.Empty;
+
+    /// <summary>规则类型 TIMEOUT / SHORTAGE / OUT_OF_RANGE。调度器据此分派 evaluator。</summary>
+    public string RuleType { get; init; } = string.Empty;
+
+    /// <summary>报警机制 MANUAL_REPORT / DATE / RATIO / VALUE_RANGE。由 <see cref="RuleType"/> 决定,不独立选择。</summary>
+    public string RuleMechanism { get; init; } = string.Empty;
+
+    /// <summary>源对象类型。**参与 dedup_key**,改动会让历史异常与新异常断代。</summary>
+    public string SourceObjectType { get; init; } = string.Empty;
+
+    /// <summary>场景编码 S1–S7。</summary>
+    public string SceneCode { get; init; } = string.Empty;
+
+    /// <summary>S_STAGE 维度节点。由 <see cref="SceneCode"/> 派生,不独立选择。</summary>
+    public string StageCode { get; init; } = string.Empty;
+
+    /// <summary>ORDER_FLOW 维度节点;可空。</summary>
+    public string? OrderFlowCode { get; init; }
+
+    /// <summary>建单时使用的异常类型编码。决定 SLA / 通知分层 / Workflow 绑定,因此必须是定义而非参数。</summary>
+    public string ExceptionTypeCode { get; init; } = string.Empty;
+
+    /// <summary>TIMEOUT 判定语义。<see cref="RuleType"/> = TIMEOUT 时必填。</summary>
+    public S8TimeoutSemantics? Timeout { get; init; }
+
+    /// <summary>SHORTAGE 判定语义。<see cref="RuleType"/> = SHORTAGE 时必填。</summary>
+    public S8ShortageSemantics? Shortage { get; init; }
+
+    /// <summary>OUT_OF_RANGE 判定语义。<see cref="RuleType"/> = OUT_OF_RANGE 时必填。</summary>
+    public S8OutOfRangeSemantics? OutOfRange { get; init; }
+
+    /// <summary>本规则开放给租户调整的运行参数白名单与取值域。</summary>
+    public S8RuleParameterPolicy Parameters { get; init; } = new();
+}
+
+/// <summary>
+/// TIMEOUT 判定语义。
+///
+/// <para><b>这些是列名,但不是"让用户填的列名"</b>:它们描述 canonical 行契约里哪一列承载到期时间、
+/// 哪一列承载状态。Provider 按 <see cref="S8CanonicalColumns"/> 产出行,因此默认值就是 canonical 名;
+/// 声明出来是为了让「判定读了哪一列」这件事有一处可被测试断言的书面记录,
+/// 而不是散在 evaluator 的 if 分支里。</para>
+/// </summary>
+public sealed class S8TimeoutSemantics
+{
+    /// <summary>到期时间列。</summary>
+    public string DueAtColumn { get; init; } = S8CanonicalColumns.DueAt;
+
+    /// <summary>状态列。</summary>
+    public string StatusColumn { get; init; } = S8CanonicalColumns.Status;
+
+    /// <summary>去重身份列(写入 <c>exception.source_object_id</c> 并参与 dedup_key)。</summary>
+    public string SourceObjectIdColumn { get; init; } = S8CanonicalColumns.SourceObjectId;
+
+    /// <summary>关联单号列(写入 <c>exception.related_object_code</c>)。</summary>
+    public string RelatedObjectCodeColumn { get; init; } = S8CanonicalColumns.RelatedObjectCode;
+
+    /// <summary>
+    /// 视为「已完成、不再超期」的状态集合。比对**忽略大小写**(沿用既有 evaluator 口径)。
+    /// 这是最典型的「看起来像参数、实际是业务定义」的字段:改一个值,同一条规则就换了一套业务含义。
+    /// </summary>
+    public IReadOnlyList<string> CompletedStates { get; init; } = Array.Empty<string>();
+}
+
+/// <summary>SHORTAGE 判定语义。当前无任何规则使用(仓内尚无 SHORTAGE 定义),保留以保证三类 evaluator 结构一致。</summary>
+public sealed class S8ShortageSemantics
+{
+    public string TargetQtyColumn { get; init; } = S8CanonicalColumns.TargetQty;
+    public string ActualQtyColumn { get; init; } = S8CanonicalColumns.ActualQty;
+    public string SourceObjectIdColumn { get; init; } = S8CanonicalColumns.SourceObjectId;
+    public string RelatedObjectCodeColumn { get; init; } = S8CanonicalColumns.RelatedObjectCode;
+
+    /// <summary>绝对容差。缺口需**大于**该值才命中。</summary>
+    public decimal ToleranceAbs { get; init; }
+
+    /// <summary>比例容差(0–1)。缺口占目标的比例需**大于**该值才命中。</summary>
+    public decimal ToleranceRatio { get; init; }
+}
+
+/// <summary>OUT_OF_RANGE 判定语义。当前无任何规则使用,同上。</summary>
+public sealed class S8OutOfRangeSemantics
+{
+    public string MeasuredValueColumn { get; init; } = S8CanonicalColumns.MeasuredValue;
+    public string SourceObjectIdColumn { get; init; } = S8CanonicalColumns.SourceObjectId;
+    public string RelatedObjectCodeColumn { get; init; } = S8CanonicalColumns.RelatedObjectCode;
+
+    /// <summary>行内下限列;为空表示使用 <see cref="LowerBound"/> 固定值。</summary>
+    public string? LowerBoundColumn { get; init; }
+
+    /// <summary>行内上限列;为空表示使用 <see cref="UpperBound"/> 固定值。</summary>
+    public string? UpperBoundColumn { get; init; }
+
+    public decimal? LowerBound { get; init; }
+    public decimal? UpperBound { get; init; }
+    public decimal ToleranceAbs { get; init; }
+    public decimal ToleranceRatio { get; init; }
+}
+
+/// <summary>
+/// 运行参数白名单与取值域。
+///
+/// <para>这里回答的是一个很窄的问题:<b>租户管理员能改什么,改到什么范围</b>。
+/// 不在本策略里的字段,就没有任何 API 能改到——不是"前端没做入口",是写模型里根本不存在该字段。</para>
+///
+/// <para>取值域随定义走而不是写死在 service:不同规则对「宽限多久算合理」的判断本就不同,
+/// 而把它写在 service 的 if 里,等于把业务口径藏进了实现。</para>
+/// </summary>
+public sealed class S8RuleParameterPolicy
+{
+    public int PollIntervalSecondsMin { get; init; } = 60;
+    public int PollIntervalSecondsMax { get; init; } = 86400;
+    public int PollIntervalSecondsDefault { get; init; } = 300;
+
+    public int TriggerCountRequiredMin { get; init; } = 1;
+    public int TriggerCountRequiredMax { get; init; } = 10;
+    public int TriggerCountRequiredDefault { get; init; } = 1;
+
+    public int RecoverCountRequiredMin { get; init; } = 1;
+    public int RecoverCountRequiredMax { get; init; } = 10;
+    public int RecoverCountRequiredDefault { get; init; } = 1;
+
+    /// <summary>
+    /// 宽限分钟下限恒为 0。**刻意不设业务上限**:本批没有依据判断「多久算过长」,
+    /// 凭空设一个上限会在没有任何证据的情况下否掉合法配置。
+    /// </summary>
+    public int GraceMinutesMin { get; init; } = 0;
+
+    public int GraceMinutesMax { get; init; } = int.MaxValue;
+    public int GraceMinutesDefault { get; init; } = 0;
+
+    /// <summary>
+    /// 允许的严重度。**刻意只有两值**:<c>S8SeverityCode.IsValid</c> 是宽松六值版
+    /// (含 LOW/MEDIUM/HIGH/CRITICAL),那是给 legacy 查询参数兼容用的,
+    /// 拿来当写入门禁会直接放行 legacy 值(DB 里 severity='HIGH' 那一行正是这样进来的)。
+    /// </summary>
+    public IReadOnlyList<string> AllowedSeverities { get; init; } =
+        new[] { S8SeverityCode.Follow, S8SeverityCode.Serious };
+
+    public string SeverityDefault { get; init; } = S8SeverityCode.Follow;
+
+    /// <summary>是否允许配置发生 / 责任部门兜底。</summary>
+    public bool AllowsDepartmentDefaults { get; init; } = true;
+}

+ 36 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8EffectiveRule.cs

@@ -0,0 +1,36 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// S8-RULE-GOVERNANCE-BATCH1:一条规则在本次执行中的**生效形态**
+/// —— 代码定义 + 租户运行参数 + 数据库行三者合一。
+///
+/// <para>存在的理由是让「语义来自哪里」在类型上就不可混淆:
+/// evaluator 拿到的是 <see cref="Definition"/> 与 <see cref="Parameters"/>,
+/// <see cref="Row"/> 只用于 Id / RuleCode / 归属列这类**非语义**用途。
+/// 谁要是想从 <c>Row.RuleType</c> 取判定类型,一眼就能在 review 里看出来。</para>
+/// </summary>
+public sealed class S8EffectiveRule
+{
+    public S8EffectiveRule(AdoS8WatchRule row, S8RuleDefinition definition, S8RuleRuntimeParameters parameters)
+    {
+        Row = row;
+        Definition = definition;
+        Parameters = parameters;
+    }
+
+    /// <summary>数据库行。仅提供 Id / RuleCode / TenantId / FactoryId 等非语义信息。</summary>
+    public AdoS8WatchRule Row { get; }
+
+    /// <summary>代码定义。业务语义的**唯一**真源。</summary>
+    public S8RuleDefinition Definition { get; }
+
+    /// <summary>租户运行参数。</summary>
+    public S8RuleRuntimeParameters Parameters { get; }
+
+    /// <summary>按行 + 定义解析生效形态。</summary>
+    public static S8EffectiveRule Resolve(AdoS8WatchRule row, S8RuleDefinition definition) =>
+        new(row, definition, S8RuleRuntimeParameters.Resolve(row, definition));
+}

+ 0 - 66
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeParams.cs

@@ -1,66 +0,0 @@
-using System.Text.Json;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
-
-/// <summary>
-/// R3 OUT_OF_RANGE 类规则 params_json 解析结果。internal 可见,供测试覆盖。
-/// 兼容两种协议形态:
-/// 1. 新协议:measuredValueField / lowerBound / upperBound / lowerBoundField / upperBoundField / objectCodeField / objectIdField / toleranceAbs / toleranceRatio / exceptionTypeCode
-/// 2. 旧 G01 兼容:mode=single_threshold / operator=&quot;&gt;&quot;|&quot;&gt;=&quot;|&quot;&lt;&quot;|&quot;&lt;=&quot; / threshold / valueField / objectCodeField / compatAlertRuleCode
-/// 字段缺失或非法 JSON:调用方负责降级(evaluator 静默返空命中),不在此抛出业务异常。
-/// </summary>
-internal sealed class S8OutOfRangeParams
-{
-    public string? MeasuredValueField { get; set; }
-    public string? ObjectCodeField { get; set; }
-    public string? ObjectIdField { get; set; }
-    public string? LowerBoundField { get; set; }
-    public string? UpperBoundField { get; set; }
-    public decimal? LowerBound { get; set; }
-    public decimal? UpperBound { get; set; }
-    public decimal ToleranceAbs { get; set; }
-    public decimal ToleranceRatio { get; set; }
-    public string? ExceptionTypeCode { get; set; }
-
-    /// <summary>解析 params_json;非法 JSON 抛 <see cref="JsonException"/>,由上游捕获。</summary>
-    public static S8OutOfRangeParams Parse(string json)
-    {
-        using var doc = JsonDocument.Parse(json);
-        var root = doc.RootElement;
-        var p = new S8OutOfRangeParams();
-
-        // 新协议字段
-        if (root.TryGetProperty("measuredValueField", out var v1)) p.MeasuredValueField = v1.GetString();
-        if (root.TryGetProperty("objectCodeField", out var v2)) p.ObjectCodeField = v2.GetString();
-        if (root.TryGetProperty("objectIdField", out var v3)) p.ObjectIdField = v3.GetString();
-        if (root.TryGetProperty("lowerBoundField", out var v4)) p.LowerBoundField = v4.GetString();
-        if (root.TryGetProperty("upperBoundField", out var v5)) p.UpperBoundField = v5.GetString();
-        if (root.TryGetProperty("lowerBound", out var v6) && v6.ValueKind == JsonValueKind.Number) p.LowerBound = v6.GetDecimal();
-        if (root.TryGetProperty("upperBound", out var v7) && v7.ValueKind == JsonValueKind.Number) p.UpperBound = v7.GetDecimal();
-        if (root.TryGetProperty("toleranceAbs", out var v8) && v8.ValueKind == JsonValueKind.Number) p.ToleranceAbs = v8.GetDecimal();
-        if (root.TryGetProperty("toleranceRatio", out var v9) && v9.ValueKind == JsonValueKind.Number) p.ToleranceRatio = v9.GetDecimal();
-        if (root.TryGetProperty("exceptionTypeCode", out var v10)) p.ExceptionTypeCode = v10.GetString();
-
-        // G01 兼容:valueField → measuredValueField
-        if (string.IsNullOrWhiteSpace(p.MeasuredValueField)
-            && root.TryGetProperty("valueField", out var legacyVal))
-            p.MeasuredValueField = legacyVal.GetString();
-
-        // G01 兼容:mode=single_threshold + operator + threshold → upperBound 或 lowerBound
-        if (root.TryGetProperty("mode", out var modeEl)
-            && string.Equals(modeEl.GetString(), "single_threshold", StringComparison.OrdinalIgnoreCase)
-            && root.TryGetProperty("operator", out var opEl)
-            && root.TryGetProperty("threshold", out var thrEl)
-            && thrEl.ValueKind == JsonValueKind.Number)
-        {
-            var op = opEl.GetString()?.Trim();
-            var thr = thrEl.GetDecimal();
-            // operator '>' 或 '>=' → measured 超过 threshold 命中 → 等价 upperBound=threshold
-            // operator '<' 或 '<=' → measured 低于 threshold 命中 → 等价 lowerBound=threshold
-            if (op == ">" || op == ">=") p.UpperBound ??= thr;
-            else if (op == "<" || op == "<=") p.LowerBound ??= thr;
-        }
-
-        return p;
-    }
-}

+ 64 - 67
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -2,13 +2,20 @@ using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 using Microsoft.Extensions.Logging;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 
 /// <summary>
-/// R3 OUT_OF_RANGE 类规则 evaluator MVP("测量值越界")。
-/// 三种最小模式:固定上限 / 固定下限 / 行内上下限;兼容 G01 single_threshold 协议。
+/// OUT_OF_RANGE 类规则 evaluator("测量值越界")。
+/// 三种最小模式:固定上限 / 固定下限 / 行内上下限。
+///
+/// <para><b>S8-RULE-GOVERNANCE-BATCH1</b>:上下限、容差、测量列、异常类型、去重身份
+/// 改由 <see cref="S8RuleDefinition"/> 供给,不再从 params_json 解析
+/// (G01 的 single_threshold 兼容协议随 params 解析一并退役 —— 它服务的是"用户在页面里
+/// 拼阈值"这个已经取消的能力)。当前仓内**没有任何 OUT_OF_RANGE 规则定义**,
+/// 因此本 evaluator 实际不会被执行,这正是期望行为。</para>
 /// 判定(任一命中即 HIT):
 ///   measured &gt; upperBound + toleranceAbs 且 (measured - upperBound) / |upperBound| &gt; toleranceRatio → ABOVE_UPPER
 ///   measured &lt; lowerBound - toleranceAbs 且 (lowerBound - measured) / |lowerBound| &gt; toleranceRatio → BELOW_LOWER
@@ -23,24 +30,17 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
     public const string RuleTypeCode = "OUT_OF_RANGE";
     public string RuleType => RuleTypeCode;
 
-    private const string DefaultExceptionTypeCode = "EQUIP_FAULT";
-
-    // S8-WATCH-EXPRESSION-COLUMN-CONTRACT-SHORTAGE-OUTOFRANGE-FIX-1:S8ConfigDraftService.BuildExpression
-    // 把 VALUE_RANGE 结果列统一别名为以下 canonical 名(无论源表真实列名为何)。evaluator 优先按 canonical
-    // 读取,仅当结果集不含 canonical 列时才回退到 params_json 指定的真实列名(兼容历史/手工规则)。
-    // 上下限为行内可选字段、无 canonical 契约,沿用 params 既有逻辑不变。
-    private const string CanonicalMeasuredValueColumn = S8CanonicalColumns.MeasuredValue;
-    private const string CanonicalSourceObjectIdColumn = S8CanonicalColumns.SourceObjectId;
-    private const string CanonicalRelatedObjectCodeColumn = S8CanonicalColumns.RelatedObjectCode;
-
     private readonly S8MonitoringDataGateway _dataGateway;
+    private readonly IS8RuleCatalog _ruleCatalog;
     private readonly ILogger<S8OutOfRangeRuleEvaluator> _logger;
 
     public S8OutOfRangeRuleEvaluator(
         S8MonitoringDataGateway dataGateway,
+        IS8RuleCatalog ruleCatalog,
         ILogger<S8OutOfRangeRuleEvaluator> logger)
     {
         _dataGateway = dataGateway;
+        _ruleCatalog = ruleCatalog;
         _logger = logger;
     }
 
@@ -50,28 +50,37 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default)
     {
-        // R5 evaluator 失败语义保护:与 TIMEOUT/SHORTAGE 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
-        // S8-STANDARD-DATASET-HARD-CUTOVER-1:规则不再自带 SQL,唯一必需配置是 params_json。
-        // dataset_code 的存在性与可用性由 S8MonitoringDataGateway 统一判定,此处不重复。
-        if (string.IsNullOrWhiteSpace(rule.ParamsJson))
-            throw new S8RuleEvaluatorException("rule_not_configured", $"OUT_OF_RANGE 规则 {{rule.RuleCode}} 缺少 params_json");
-
-        S8OutOfRangeParams parameters;
-        try { parameters = S8OutOfRangeParams.Parse(rule.ParamsJson!); }
-        catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
-
-        if (string.IsNullOrWhiteSpace(parameters.MeasuredValueField))
-            throw new S8RuleEvaluatorException("params_schema_invalid", $"OUT_OF_RANGE 规则 {rule.RuleCode} params 缺少必填字段 measuredValueField");
+        // S8-RULE-GOVERNANCE-BATCH1:没有代码定义的规则一律不执行(详见 S8TimeoutRuleEvaluator 同处注释)。
+        var effective = ResolveEffective(rule);
 
         // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 maxRows(env 优先,回退代码默认)。
-        // S8-STANDARD-DATASET-HARD-CUTOVER-1:不再解析 commandTimeout —— S8 已不执行 SQL,
-        // 超时归 Provider 自己的取数实现负责。
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
 
         var data = await _dataGateway.LoadAsync(
             tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
 
-        return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, DateTime.Now);
+        return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
+    }
+
+    /// <summary>解析生效规则:定义必须存在、类型必须匹配、OUT_OF_RANGE 语义必须齐备。</summary>
+    private S8EffectiveRule ResolveEffective(AdoS8WatchRule rule)
+    {
+        var definition = _ruleCatalog.TryGet(rule.RuleCode)
+            ?? throw new S8RuleEvaluatorException(
+                S8RuleCatalog.ReasonNotFound,
+                $"规则 {rule.RuleCode} 在当前版本中没有代码定义,不予执行");
+
+        if (!string.Equals(definition.RuleType, RuleTypeCode, StringComparison.Ordinal))
+            throw new S8RuleEvaluatorException(
+                "rule_type_mismatch",
+                $"规则 {rule.RuleCode} 的代码定义类型为 {definition.RuleType},不能由 OUT_OF_RANGE evaluator 执行");
+
+        if (definition.OutOfRange == null)
+            throw new S8RuleEvaluatorException(
+                "rule_semantics_missing",
+                $"规则 {rule.RuleCode} 的代码定义缺少 OUT_OF_RANGE 判定语义");
+
+        return S8EffectiveRule.Resolve(rule, definition);
     }
 
     /// <summary>
@@ -80,24 +89,23 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
     /// </summary>
     internal static List<S8RuleHit> EvaluateRows(
         S8MonitoringRowSet rowSet,
-        S8OutOfRangeParams parameters,
-        AdoS8WatchRule rule,
+        S8EffectiveRule effective,
         long tenantId,
         long factoryId,
         DateTime detectedAt)
     {
+        var definition = effective.Definition;
+        var semantics = definition.OutOfRange!;
+        var parameters = effective.Parameters;
+        var rule = effective.Row;
+
         var hits = new List<S8RuleHit>();
-        var exceptionTypeCode = string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode)
-            ? DefaultExceptionTypeCode
-            : parameters.ExceptionTypeCode!;
-        var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
-            ? rule.WatchObjectType
-            : rule.SourceObjectType!;
-
-        // 结果列名一次性解析(结果集列在整个结果集内稳定):canonical 优先,缺失回退 params 真实列名。
-        var measuredValueColumn = ResolveResultColumn(rowSet, CanonicalMeasuredValueColumn, parameters.MeasuredValueField);
-        var objectCodeColumn = ResolveResultColumn(rowSet, CanonicalRelatedObjectCodeColumn, parameters.ObjectCodeField);
-        var objectIdColumn = ResolveResultColumn(rowSet, CanonicalSourceObjectIdColumn, parameters.ObjectIdField);
+        var exceptionTypeCode = definition.ExceptionTypeCode;
+        var sourceObjectType = definition.SourceObjectType;
+
+        var measuredValueColumn = semantics.MeasuredValueColumn;
+        var objectCodeColumn = semantics.RelatedObjectCodeColumn;
+        var objectIdColumn = semantics.SourceObjectIdColumn;
 
         foreach (var row in rowSet.Rows)
         {
@@ -105,30 +113,30 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
             if (measured == null) continue;
 
             // 行内上下限优先;缺失时回退到固定上下限。
-            decimal? lower = !string.IsNullOrWhiteSpace(parameters.LowerBoundField)
-                ? row.GetDecimal(parameters.LowerBoundField!) ?? parameters.LowerBound
-                : parameters.LowerBound;
-            decimal? upper = !string.IsNullOrWhiteSpace(parameters.UpperBoundField)
-                ? row.GetDecimal(parameters.UpperBoundField!) ?? parameters.UpperBound
-                : parameters.UpperBound;
+            decimal? lower = !string.IsNullOrWhiteSpace(semantics.LowerBoundColumn)
+                ? row.GetDecimal(semantics.LowerBoundColumn!) ?? semantics.LowerBound
+                : semantics.LowerBound;
+            decimal? upper = !string.IsNullOrWhiteSpace(semantics.UpperBoundColumn)
+                ? row.GetDecimal(semantics.UpperBoundColumn!) ?? semantics.UpperBound
+                : semantics.UpperBound;
 
             if (lower == null && upper == null) continue; // 无界不命中
 
             string? direction = null;
             decimal deviation = 0m;
-            if (upper != null && measured.Value > upper.Value + parameters.ToleranceAbs)
+            if (upper != null && measured.Value > upper.Value + semantics.ToleranceAbs)
             {
                 var dev = measured.Value - upper.Value;
-                if (upper.Value == 0m || dev / Math.Abs(upper.Value) > parameters.ToleranceRatio)
+                if (upper.Value == 0m || dev / Math.Abs(upper.Value) > semantics.ToleranceRatio)
                 {
                     direction = "ABOVE_UPPER";
                     deviation = dev;
                 }
             }
-            if (direction == null && lower != null && measured.Value < lower.Value - parameters.ToleranceAbs)
+            if (direction == null && lower != null && measured.Value < lower.Value - semantics.ToleranceAbs)
             {
                 var dev = lower.Value - measured.Value;
-                if (lower.Value == 0m || dev / Math.Abs(lower.Value) > parameters.ToleranceRatio)
+                if (lower.Value == 0m || dev / Math.Abs(lower.Value) > semantics.ToleranceRatio)
                 {
                     direction = "BELOW_LOWER";
                     deviation = dev;
@@ -141,24 +149,24 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
 
             var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
 
-            var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
+            var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
             hits.Add(new S8RuleHit
             {
                 SourceRuleId = rule.Id,
-                SourceRuleCode = rule.RuleCode,
+                SourceRuleCode = definition.RuleCode,
                 SourceObjectType = sourceObjectType,
                 SourceObjectId = sourceObjectId,
                 RelatedObjectCode = relatedObjectCode,
                 ExceptionTypeCode = exceptionTypeCode,
-                SceneCode = rule.SceneCode,
-                Severity = S8SeverityCode.Normalize(rule.Severity),
+                SceneCode = definition.SceneCode,
+                Severity = parameters.Severity,
                 DedupKey = dedupKey,
                 SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, measured.Value, lower, upper, deviation, direction, exceptionTypeCode),
                 DetectedAt = detectedAt,
                 Title = BuildTitle(sourceObjectType, sourceObjectId, measured.Value, lower, upper, direction),
-                OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId),
-                ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId)
+                OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId) ?? parameters.DefaultOccurrenceDeptId,
+                ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId) ?? parameters.DefaultResponsibleDeptId
             });
         }
 
@@ -194,15 +202,4 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         return JsonSerializer.Serialize(payload);
     }
 
-    /// <summary>
-    /// 结果列名解析:BuildExpression 已把 VALUE_RANGE 结果列统一别名为 canonical(measured_value/
-    /// source_object_id/related_object_code)。优先返回 canonical 列名;仅当结果集不含 canonical 列时,
-    /// 回退到 params_json 指定的真实列名(兼容历史/手工规则)。仅在 canonical 与 params 字段之间二选一,
-    /// 不新增无依据兜底字段。
-    /// </summary>
-    private static string ResolveResultColumn(S8MonitoringRowSet rowSet, string canonicalColumn, string? paramsColumn)
-    {
-        if (rowSet.HasColumn(canonicalColumn)) return canonicalColumn;
-        return string.IsNullOrWhiteSpace(paramsColumn) ? canonicalColumn : paramsColumn!;
-    }
 }

+ 163 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleRuntimeParameters.cs

@@ -0,0 +1,163 @@
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// S8-RULE-GOVERNANCE-BATCH1:一条规则在某租户下的**运行参数**(B 类)。
+///
+/// <para><b>与 Definition(A 类)的分界线</b>:改这里的值,规则「判定什么」不变,只是
+/// 「多久跑一次、抖动几次算数、宽限多久、报多严重、兜底派给谁」变了。
+/// 改 Definition 才会改变业务含义 —— 而 Definition 在代码里,没有 API 能改。</para>
+///
+/// <para><b>params_json 的新职责</b>:只承载 <see cref="GraceMinutes"/> /
+/// <see cref="DefaultOccurrenceDeptId"/> / <see cref="DefaultResponsibleDeptId"/> 三项。
+/// 其余参数是实体列(poll / trigger / recover / severity),不进 JSON。</para>
+///
+/// <para><b>历史 JSON 兼容(本批不做 migration)</b>:库里现存的 params_json 可能是
+/// 旧的 A+B 全量 JSON、可能只有 B、也可能是 NULL。三种都必须能起来:
+/// <list type="bullet">
+///   <item>旧 A 字段(dueAtField / statusField / completedStates / objectIdField /
+///         objectCodeField / exceptionTypeCode)→ <b>读到也忽略</b>,绝不作为语义来源;</item>
+///   <item>B 字段存在 → 正常解析;</item>
+///   <item>params_json 为 NULL / 空白 / 非法 JSON → 全部回落 Definition 声明的默认值,
+///         <b>不再抛 rule_not_configured</b>。</item>
+/// </list>
+/// 最后一条是 G1 的直接修复点:本地 Rule 01 的 params_json 已被
+/// <c>PUT /params {"enabled":false}</c> 抹成 NULL,在旧代码里这条规则已经永久跑不起来。</para>
+/// </summary>
+public sealed class S8RuleRuntimeParameters
+{
+    /// <summary>轮询间隔(秒)。真源是实体列 <c>poll_interval_seconds</c>。</summary>
+    public int PollIntervalSeconds { get; init; }
+
+    /// <summary>连续命中 N 次才建单。真源是实体列 <c>trigger_count_required</c>。</summary>
+    public int TriggerCountRequired { get; init; }
+
+    /// <summary>连续未命中 N 次才标 recovered。真源是实体列 <c>recover_count_required</c>。</summary>
+    public int RecoverCountRequired { get; init; }
+
+    /// <summary>严重度。真源是实体列 <c>severity</c>;非法值回落 Definition 默认,不静默降级为 FOLLOW。</summary>
+    public string Severity { get; init; } = S8SeverityCode.Follow;
+
+    /// <summary>宽限分钟。真源是 params_json。</summary>
+    public int GraceMinutes { get; init; }
+
+    /// <summary>发生部门兜底。真源是 params_json;未配置为 null。</summary>
+    public long? DefaultOccurrenceDeptId { get; init; }
+
+    /// <summary>责任部门兜底。真源是 params_json;未配置为 null。</summary>
+    public long? DefaultResponsibleDeptId { get; init; }
+
+    // ─────────────────────────────────────────────────────────────
+    // params_json 的 B 类键名。写入与读取共用同一批常量,杜绝两处拼写漂移。
+    // ─────────────────────────────────────────────────────────────
+    internal const string KeyGraceMinutes = "graceMinutes";
+    internal const string KeyDefaultOccurrenceDeptId = "defaultOccurrenceDeptId";
+    internal const string KeyDefaultResponsibleDeptId = "defaultResponsibleDeptId";
+
+    /// <summary>
+    /// 从实体行 + Definition 解析出生效运行参数。
+    /// <b>本方法不抛异常</b>:任何解析失败都回落默认值。运行参数解析失败不应该让一条
+    /// 业务语义完全正确的规则停摆 —— 那正是 G1 造成的后果。
+    /// </summary>
+    public static S8RuleRuntimeParameters Resolve(AdoS8WatchRule rule, S8RuleDefinition definition)
+    {
+        var policy = definition.Parameters ?? new S8RuleParameterPolicy();
+
+        var graceMinutes = policy.GraceMinutesDefault;
+        long? occurrenceDeptId = null;
+        long? responsibleDeptId = null;
+
+        if (!string.IsNullOrWhiteSpace(rule.ParamsJson))
+        {
+            try
+            {
+                using var doc = JsonDocument.Parse(rule.ParamsJson!);
+                var root = doc.RootElement;
+                if (root.ValueKind == JsonValueKind.Object)
+                {
+                    // 只读 B 类三键。旧 JSON 里的 A 类字段就在同一个对象里,
+                    // 这里**不读**它们 —— 忽略是刻意的,不是遗漏。
+                    if (root.TryGetProperty(KeyGraceMinutes, out var g)
+                        && g.ValueKind == JsonValueKind.Number
+                        && g.TryGetInt32(out var gv))
+                        graceMinutes = gv;
+
+                    occurrenceDeptId = ReadNullableLong(root, KeyDefaultOccurrenceDeptId);
+                    responsibleDeptId = ReadNullableLong(root, KeyDefaultResponsibleDeptId);
+                }
+            }
+            catch (JsonException)
+            {
+                // 非法 JSON:回落默认值。历史脏数据不该把规则打死。
+            }
+        }
+
+        if (!policy.AllowsDepartmentDefaults)
+        {
+            occurrenceDeptId = null;
+            responsibleDeptId = null;
+        }
+
+        return new S8RuleRuntimeParameters
+        {
+            PollIntervalSeconds = Clamp(rule.PollIntervalSeconds, policy.PollIntervalSecondsMin,
+                policy.PollIntervalSecondsMax, policy.PollIntervalSecondsDefault),
+            TriggerCountRequired = Clamp(rule.TriggerCountRequired, policy.TriggerCountRequiredMin,
+                policy.TriggerCountRequiredMax, policy.TriggerCountRequiredDefault),
+            RecoverCountRequired = Clamp(rule.RecoverCountRequired, policy.RecoverCountRequiredMin,
+                policy.RecoverCountRequiredMax, policy.RecoverCountRequiredDefault),
+            Severity = ResolveSeverity(rule.Severity, policy),
+            GraceMinutes = Clamp(graceMinutes, policy.GraceMinutesMin, policy.GraceMinutesMax,
+                policy.GraceMinutesDefault),
+            DefaultOccurrenceDeptId = occurrenceDeptId,
+            DefaultResponsibleDeptId = responsibleDeptId
+        };
+    }
+
+    /// <summary>
+    /// 把 B 类参数序列化回 params_json。
+    /// <b>只写这三个键</b>:任何旧 A 类字段在这次写入后自然消失,无需 migration 也不会被再次读到。
+    /// </summary>
+    public string ToParamsJson()
+    {
+        var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
+        {
+            [KeyGraceMinutes] = GraceMinutes
+        };
+        if (DefaultOccurrenceDeptId.HasValue) payload[KeyDefaultOccurrenceDeptId] = DefaultOccurrenceDeptId.Value;
+        if (DefaultResponsibleDeptId.HasValue) payload[KeyDefaultResponsibleDeptId] = DefaultResponsibleDeptId.Value;
+        return JsonSerializer.Serialize(payload);
+    }
+
+    private static long? ReadNullableLong(JsonElement root, string key)
+    {
+        if (!root.TryGetProperty(key, out var el)) return null;
+        if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v)) return v > 0 ? v : null;
+        // 历史数据里存在字符串化的 id,容忍解析但不容忍非法值。
+        if (el.ValueKind == JsonValueKind.String && long.TryParse(el.GetString(), out var sv)) return sv > 0 ? sv : null;
+        return null;
+    }
+
+    private static int Clamp(int value, int min, int max, int fallback)
+    {
+        if (value < min || value > max) return fallback;
+        return value;
+    }
+
+    /// <summary>
+    /// 严重度解析。**刻意不复用 <c>S8SeverityCode.Normalize</c>**:它的兜底分支是
+    /// <c>_ =&gt; Follow</c>,会把 legacy 的 HIGH / 拼写错误静默降级成 FOLLOW;
+    /// 那正是库里 severity='HIGH' 的行当年混进来的方式。这里回落 Definition 声明的默认值。
+    /// </summary>
+    private static string ResolveSeverity(string? severity, S8RuleParameterPolicy policy)
+    {
+        if (!string.IsNullOrWhiteSpace(severity)
+            && policy.AllowedSeverities.Contains(severity!.Trim(), StringComparer.Ordinal))
+            return severity!.Trim();
+        return policy.SeverityDefault;
+    }
+}

+ 0 - 34
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageParams.cs

@@ -1,34 +0,0 @@
-using System.Text.Json;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
-
-/// <summary>
-/// R3 SHORTAGE 类规则 params_json 解析结果。internal 可见,供测试覆盖。
-/// 字段缺失或非法 JSON:调用方负责降级(evaluator 静默返空命中),不在此抛出业务异常。
-/// </summary>
-internal sealed class S8ShortageParams
-{
-    public string? TargetQtyField { get; set; }
-    public string? ActualQtyField { get; set; }
-    public string? ObjectCodeField { get; set; }
-    public string? ObjectIdField { get; set; }
-    public decimal ToleranceAbs { get; set; }
-    public decimal ToleranceRatio { get; set; }
-    public string? ExceptionTypeCode { get; set; }
-
-    /// <summary>解析 params_json;非法 JSON 抛 <see cref="JsonException"/>,由上游捕获。</summary>
-    public static S8ShortageParams Parse(string json)
-    {
-        using var doc = JsonDocument.Parse(json);
-        var root = doc.RootElement;
-        var p = new S8ShortageParams();
-        if (root.TryGetProperty("targetQtyField", out var v1)) p.TargetQtyField = v1.GetString();
-        if (root.TryGetProperty("actualQtyField", out var v2)) p.ActualQtyField = v2.GetString();
-        if (root.TryGetProperty("objectCodeField", out var v3)) p.ObjectCodeField = v3.GetString();
-        if (root.TryGetProperty("objectIdField", out var v4)) p.ObjectIdField = v4.GetString();
-        if (root.TryGetProperty("toleranceAbs", out var v5) && v5.ValueKind == JsonValueKind.Number) p.ToleranceAbs = v5.GetDecimal();
-        if (root.TryGetProperty("toleranceRatio", out var v6) && v6.ValueKind == JsonValueKind.Number) p.ToleranceRatio = v6.GetDecimal();
-        if (root.TryGetProperty("exceptionTypeCode", out var v7)) p.ExceptionTypeCode = v7.GetString();
-        return p;
-    }
-}

+ 58 - 47
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs

@@ -2,15 +2,19 @@ using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 using Microsoft.Extensions.Logging;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 
 /// <summary>
-/// R3 SHORTAGE 类规则 evaluator MVP("实际数量未达目标")。
-/// params_json 约定(首版最小集合):
-///   { targetQtyField, actualQtyField, objectCodeField, objectIdField,
-///     toleranceAbs, toleranceRatio, exceptionTypeCode }
+/// SHORTAGE 类规则 evaluator("实际数量未达目标")。
+///
+/// <para><b>S8-RULE-GOVERNANCE-BATCH1</b>:判定语义(数量列、容差、异常类型、去重身份)
+/// 改由 <see cref="S8RuleDefinition"/> 供给,不再从 params_json 解析。
+/// 当前仓内**没有任何 SHORTAGE 规则定义**,因此本 evaluator 实际不会被执行 ——
+/// 这正是期望行为:没有代码定义的规则不得运行。</para>
+///
 /// 判定:
 ///   shortage = target - actual;
 ///   target &lt;= 0 不命中(防除零 / 脏数据);
@@ -27,13 +31,16 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
     public string RuleType => RuleTypeCode;
 
     private readonly S8MonitoringDataGateway _dataGateway;
+    private readonly IS8RuleCatalog _ruleCatalog;
     private readonly ILogger<S8ShortageRuleEvaluator> _logger;
 
     public S8ShortageRuleEvaluator(
         S8MonitoringDataGateway dataGateway,
+        IS8RuleCatalog ruleCatalog,
         ILogger<S8ShortageRuleEvaluator> logger)
     {
         _dataGateway = dataGateway;
+        _ruleCatalog = ruleCatalog;
         _logger = logger;
     }
 
@@ -43,30 +50,37 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default)
     {
-        // R5 evaluator 失败语义保护:与 TIMEOUT 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
-        // S8-STANDARD-DATASET-HARD-CUTOVER-1:规则不再自带 SQL,唯一必需配置是 params_json。
-        // dataset_code 的存在性与可用性由 S8MonitoringDataGateway 统一判定,此处不重复。
-        if (string.IsNullOrWhiteSpace(rule.ParamsJson))
-            throw new S8RuleEvaluatorException("rule_not_configured", $"SHORTAGE 规则 {{rule.RuleCode}} 缺少 params_json");
-
-        S8ShortageParams parameters;
-        try { parameters = S8ShortageParams.Parse(rule.ParamsJson!); }
-        catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"SHORTAGE 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
-
-        if (string.IsNullOrWhiteSpace(parameters.TargetQtyField)
-            || string.IsNullOrWhiteSpace(parameters.ActualQtyField)
-            || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
-            throw new S8RuleEvaluatorException("params_schema_invalid", $"SHORTAGE 规则 {rule.RuleCode} params 缺少必填字段 targetQtyField/actualQtyField/exceptionTypeCode");
+        // S8-RULE-GOVERNANCE-BATCH1:没有代码定义的规则一律不执行(详见 S8TimeoutRuleEvaluator 同处注释)。
+        var effective = ResolveEffective(rule);
 
         // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 maxRows(env 优先,回退代码默认)。
-        // S8-STANDARD-DATASET-HARD-CUTOVER-1:不再解析 commandTimeout —— S8 已不执行 SQL,
-        // 超时归 Provider 自己的取数实现负责。
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
 
         var data = await _dataGateway.LoadAsync(
             tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
 
-        return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, DateTime.Now);
+        return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
+    }
+
+    /// <summary>解析生效规则:定义必须存在、类型必须匹配、SHORTAGE 语义必须齐备。</summary>
+    private S8EffectiveRule ResolveEffective(AdoS8WatchRule rule)
+    {
+        var definition = _ruleCatalog.TryGet(rule.RuleCode)
+            ?? throw new S8RuleEvaluatorException(
+                S8RuleCatalog.ReasonNotFound,
+                $"规则 {rule.RuleCode} 在当前版本中没有代码定义,不予执行");
+
+        if (!string.Equals(definition.RuleType, RuleTypeCode, StringComparison.Ordinal))
+            throw new S8RuleEvaluatorException(
+                "rule_type_mismatch",
+                $"规则 {rule.RuleCode} 的代码定义类型为 {definition.RuleType},不能由 SHORTAGE evaluator 执行");
+
+        if (definition.Shortage == null)
+            throw new S8RuleEvaluatorException(
+                "rule_semantics_missing",
+                $"规则 {rule.RuleCode} 的代码定义缺少 SHORTAGE 判定语义");
+
+        return S8EffectiveRule.Resolve(rule, definition);
     }
 
     /// <summary>
@@ -75,59 +89,56 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
     /// </summary>
     internal static List<S8RuleHit> EvaluateRows(
         S8MonitoringRowSet rowSet,
-        S8ShortageParams parameters,
-        AdoS8WatchRule rule,
+        S8EffectiveRule effective,
         long tenantId,
         long factoryId,
         DateTime detectedAt)
     {
+        var definition = effective.Definition;
+        var semantics = definition.Shortage!;
+        var parameters = effective.Parameters;
+        var rule = effective.Row;
+
         var hits = new List<S8RuleHit>();
-        var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
-            ? rule.WatchObjectType
-            : rule.SourceObjectType!;
+        var sourceObjectType = definition.SourceObjectType;
 
         foreach (var row in rowSet.Rows)
         {
-            var target = row.GetDecimal(parameters.TargetQtyField!);
-            var actual = row.GetDecimal(parameters.ActualQtyField!);
+            var target = row.GetDecimal(semantics.TargetQtyColumn);
+            var actual = row.GetDecimal(semantics.ActualQtyColumn);
 
             if (target == null || target.Value <= 0m) continue;     // 防除零 / 脏数据
             if (actual == null) continue;                            // 保守:缺 actual 不建单
 
             var shortage = target.Value - actual.Value;
-            if (shortage <= parameters.ToleranceAbs) continue;
+            if (shortage <= semantics.ToleranceAbs) continue;
 
             var ratio = shortage / target.Value;
-            if (ratio <= parameters.ToleranceRatio) continue;
+            if (ratio <= semantics.ToleranceRatio) continue;
 
-            var objectCodeField = string.IsNullOrWhiteSpace(parameters.ObjectCodeField)
-                ? S8CanonicalColumns.RelatedObjectCode
-                : parameters.ObjectCodeField!;
-            var relatedObjectCode = row.GetString(objectCodeField) ?? string.Empty;
+            var relatedObjectCode = row.GetString(semantics.RelatedObjectCodeColumn) ?? string.Empty;
             if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
 
-            var sourceObjectId = string.IsNullOrWhiteSpace(parameters.ObjectIdField)
-                ? relatedObjectCode
-                : row.GetString(parameters.ObjectIdField!) ?? relatedObjectCode;
+            var sourceObjectId = row.GetString(semantics.SourceObjectIdColumn) ?? relatedObjectCode;
 
-            var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
+            var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
             hits.Add(new S8RuleHit
             {
                 SourceRuleId = rule.Id,
-                SourceRuleCode = rule.RuleCode,
+                SourceRuleCode = definition.RuleCode,
                 SourceObjectType = sourceObjectType,
                 SourceObjectId = sourceObjectId,
                 RelatedObjectCode = relatedObjectCode,
-                ExceptionTypeCode = parameters.ExceptionTypeCode!,
-                SceneCode = rule.SceneCode,
-                Severity = S8SeverityCode.Normalize(rule.Severity),
+                ExceptionTypeCode = definition.ExceptionTypeCode,
+                SceneCode = definition.SceneCode,
+                Severity = parameters.Severity,
                 DedupKey = dedupKey,
-                SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, target.Value, actual.Value, shortage, ratio, parameters),
+                SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, target.Value, actual.Value, shortage, ratio, definition),
                 DetectedAt = detectedAt,
                 Title = $"[数量不足] {sourceObjectType} {sourceObjectId} 缺口 {shortage:0.##}(目标 {target.Value:0.##} / 实际 {actual.Value:0.##})",
-                OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId),
-                ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId)
+                OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId) ?? parameters.DefaultOccurrenceDeptId,
+                ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId) ?? parameters.DefaultResponsibleDeptId
             });
         }
 
@@ -139,7 +150,7 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
 
     private static string BuildPayload(S8MonitoringRow row, string sourceObjectType, string sourceObjectId,
-        decimal target, decimal actual, decimal shortage, decimal ratio, S8ShortageParams parameters)
+        decimal target, decimal actual, decimal shortage, decimal ratio, S8RuleDefinition definition)
     {
         var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
         foreach (var kv in row.Values)
@@ -151,7 +162,7 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         payload["__ruleType"] = RuleTypeCode;
         payload["__sourceObjectType"] = sourceObjectType;
         payload["__sourceObjectId"] = sourceObjectId;
-        payload["__exceptionTypeCode"] = parameters.ExceptionTypeCode;
+        payload["__exceptionTypeCode"] = definition.ExceptionTypeCode;
         return JsonSerializer.Serialize(payload);
     }
 }

+ 0 - 41
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutParams.cs

@@ -1,41 +0,0 @@
-using System.Text.Json;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
-
-/// <summary>
-/// R2 TIMEOUT 类规则 params_json 解析结果。internal 可见,供测试覆盖。
-/// 字段缺失或非法 JSON:调用方负责降级(evaluator 静默返空命中),不在此抛出业务异常。
-/// </summary>
-internal sealed class S8TimeoutParams
-{
-    public string? DueAtField { get; set; }
-    public string? StatusField { get; set; }
-    public List<string> CompletedStates { get; } = new();
-    public string? ObjectCodeField { get; set; }
-    public string? ObjectIdField { get; set; }
-    public int GraceMinutes { get; set; }
-    public string? ExceptionTypeCode { get; set; }
-
-    /// <summary>解析 params_json;非法 JSON 抛 <see cref="JsonException"/>,由上游捕获。</summary>
-    public static S8TimeoutParams Parse(string json)
-    {
-        using var doc = JsonDocument.Parse(json);
-        var root = doc.RootElement;
-        var p = new S8TimeoutParams();
-        if (root.TryGetProperty("dueAtField", out var v1)) p.DueAtField = v1.GetString();
-        if (root.TryGetProperty("statusField", out var v2)) p.StatusField = v2.GetString();
-        if (root.TryGetProperty("objectCodeField", out var v3)) p.ObjectCodeField = v3.GetString();
-        if (root.TryGetProperty("objectIdField", out var v4)) p.ObjectIdField = v4.GetString();
-        if (root.TryGetProperty("graceMinutes", out var v5) && v5.ValueKind == JsonValueKind.Number) p.GraceMinutes = v5.GetInt32();
-        if (root.TryGetProperty("exceptionTypeCode", out var v6)) p.ExceptionTypeCode = v6.GetString();
-        if (root.TryGetProperty("completedStates", out var v7) && v7.ValueKind == JsonValueKind.Array)
-        {
-            foreach (var s in v7.EnumerateArray())
-            {
-                var str = s.GetString();
-                if (!string.IsNullOrWhiteSpace(str)) p.CompletedStates.Add(str!);
-            }
-        }
-        return p;
-    }
-}

+ 76 - 59
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -2,41 +2,45 @@ using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 using Microsoft.Extensions.Logging;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 
 /// <summary>
-/// R2 TIMEOUT 类规则 evaluator MVP。
-/// params_json 约定(首版最小集合):
-///   { dueAtField, statusField, completedStates[], objectCodeField, objectIdField, graceMinutes, exceptionTypeCode }
+/// TIMEOUT 类规则 evaluator。
 /// 判定:dueAt &lt;= now - graceMinutes 且 status 不在 completedStates 内 → HIT。
 /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
 ///
-/// S8-DATASET-PROVIDER-FOUNDATION-1:取数改由 <see cref="S8MonitoringDataGateway"/> 承担,
-/// 本类不再直接依赖 DataTable / 数据源 / SQL。判定算法与迁移前逐行一致。
+/// S8-DATASET-PROVIDER-FOUNDATION-1:取数由 <see cref="S8MonitoringDataGateway"/> 承担,
+/// 本类不直接依赖 DataTable / 数据源 / SQL。
+///
+/// <para><b>S8-RULE-GOVERNANCE-BATCH1:判定语义改由代码定义供给。</b>
+/// 此前 <c>dueAtField</c> / <c>statusField</c> / <c>completedStates</c> / <c>objectIdField</c> /
+/// <c>exceptionTypeCode</c> 全部从 <c>params_json</c> 解析 —— 而 <c>params_json</c> 是配置页可整块
+/// 覆写的自由文本。于是「这条规则判什么」实际由业务用户决定,改一个字符串就换一套业务含义。
+/// 现在这些一律取自 <see cref="S8RuleDefinition"/>(<see cref="IS8RuleCatalog"/> 供给),
+/// params_json 只剩 graceMinutes 等真正的运行策略。</para>
+///
+/// <para>连带修复:params_json 为 NULL 不再抛 <c>rule_not_configured</c> —— 定义在代码里,
+/// 一条没有运行参数的规则完全可以按默认值跑。</para>
 /// </summary>
 public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
 {
     public const string RuleTypeCode = "TIMEOUT";
     public string RuleType => RuleTypeCode;
 
-    // S8-WATCH-EXPRESSION-COLUMN-CONTRACT-FIX-1:S8ConfigDraftService.BuildExpression 统一把结果列
-    // 别名为以下 canonical 名(无论源表真实列名为何)。evaluator 优先按 canonical 读取,仅当结果集
-    // 不含 canonical 列时才回退到 params_json 指定的真实列名(兼容历史未别名规则)。
-    private const string CanonicalDueAtColumn = S8CanonicalColumns.DueAt;
-    private const string CanonicalStatusColumn = S8CanonicalColumns.Status;
-    private const string CanonicalSourceObjectIdColumn = S8CanonicalColumns.SourceObjectId;
-    private const string CanonicalRelatedObjectCodeColumn = S8CanonicalColumns.RelatedObjectCode;
-
     private readonly S8MonitoringDataGateway _dataGateway;
+    private readonly IS8RuleCatalog _ruleCatalog;
     private readonly ILogger<S8TimeoutRuleEvaluator> _logger;
 
     public S8TimeoutRuleEvaluator(
         S8MonitoringDataGateway dataGateway,
+        IS8RuleCatalog ruleCatalog,
         ILogger<S8TimeoutRuleEvaluator> logger)
     {
         _dataGateway = dataGateway;
+        _ruleCatalog = ruleCatalog;
         _logger = logger;
     }
 
@@ -48,19 +52,11 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
     {
         // R5 evaluator 失败语义保护:所有"非命中判定"路径均改为抛出 S8RuleEvaluatorException,
         // 由 SchedulerService 标记 evaluate_failed 并跳过 recovery reconcile,避免对未确认未命中的 rule 误标 recovered_at。
-        // S8-STANDARD-DATASET-HARD-CUTOVER-1:规则不再自带 SQL,唯一必需配置是 params_json。
-        // dataset_code 的存在性与可用性由 S8MonitoringDataGateway 统一判定,此处不重复。
-        if (string.IsNullOrWhiteSpace(rule.ParamsJson))
-            throw new S8RuleEvaluatorException("rule_not_configured", $"TIMEOUT 规则 {rule.RuleCode} 缺少 params_json");
-
-        S8TimeoutParams parameters;
-        try { parameters = S8TimeoutParams.Parse(rule.ParamsJson!); }
-        catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"TIMEOUT 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
-
-        if (string.IsNullOrWhiteSpace(parameters.DueAtField)
-            || string.IsNullOrWhiteSpace(parameters.StatusField)
-            || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
-            throw new S8RuleEvaluatorException("params_schema_invalid", $"TIMEOUT 规则 {rule.RuleCode} params 缺少必填字段 dueAtField/statusField/exceptionTypeCode");
+        //
+        // S8-RULE-GOVERNANCE-BATCH1:第一道门是「这条规则在当前代码版本里有没有定义」。
+        // 没有定义 = 它的判定语义无处可取,绝不允许猜(历史实现会去 params_json 里猜,
+        // 于是任何人建一条规则、写一段 JSON 就能让调度器替他跑)。
+        var effective = ResolveEffective(rule);
 
         // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 maxRows(env 优先,回退代码默认)。
         // S8-STANDARD-DATASET-HARD-CUTOVER-1:不再解析 commandTimeout —— S8 已不执行 SQL,
@@ -70,7 +66,31 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         var data = await _dataGateway.LoadAsync(
             tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
 
-        return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, DateTime.Now);
+        return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
+    }
+
+    /// <summary>
+    /// 解析生效规则:定义必须存在、类型必须匹配、TIMEOUT 语义必须齐备。
+    /// 三者任一不满足都是**发布态问题**,不是数据问题,故 fail-fast 而非静默返空命中。
+    /// </summary>
+    private S8EffectiveRule ResolveEffective(AdoS8WatchRule rule)
+    {
+        var definition = _ruleCatalog.TryGet(rule.RuleCode)
+            ?? throw new S8RuleEvaluatorException(
+                S8RuleCatalog.ReasonNotFound,
+                $"规则 {rule.RuleCode} 在当前版本中没有代码定义,不予执行");
+
+        if (!string.Equals(definition.RuleType, RuleTypeCode, StringComparison.Ordinal))
+            throw new S8RuleEvaluatorException(
+                "rule_type_mismatch",
+                $"规则 {rule.RuleCode} 的代码定义类型为 {definition.RuleType},不能由 TIMEOUT evaluator 执行");
+
+        if (definition.Timeout == null)
+            throw new S8RuleEvaluatorException(
+                "rule_semantics_missing",
+                $"规则 {rule.RuleCode} 的代码定义缺少 TIMEOUT 判定语义");
+
+        return S8EffectiveRule.Resolve(rule, definition);
     }
 
     /// <summary>
@@ -79,28 +99,34 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
     /// </summary>
     internal static List<S8RuleHit> EvaluateRows(
         S8MonitoringRowSet rowSet,
-        S8TimeoutParams parameters,
-        AdoS8WatchRule rule,
+        S8EffectiveRule effective,
         long tenantId,
         long factoryId,
         DateTime detectedAt)
     {
+        var definition = effective.Definition;
+        var semantics = definition.Timeout!;
+        var parameters = effective.Parameters;
+        var rule = effective.Row;
+
         var hits = new List<S8RuleHit>();
         var threshold = detectedAt.AddMinutes(-parameters.GraceMinutes);
-        var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
-            ? rule.WatchObjectType
-            : rule.SourceObjectType!;
 
-        // 结果列名一次性解析(结果集列在整个结果集内稳定):canonical 优先,缺失回退 params 真实列名。
-        var statusColumn = ResolveResultColumn(rowSet, CanonicalStatusColumn, parameters.StatusField);
-        var dueAtColumn = ResolveResultColumn(rowSet, CanonicalDueAtColumn, parameters.DueAtField);
-        var objectCodeColumn = ResolveResultColumn(rowSet, CanonicalRelatedObjectCodeColumn, parameters.ObjectCodeField);
-        var objectIdColumn = ResolveResultColumn(rowSet, CanonicalSourceObjectIdColumn, parameters.ObjectIdField);
+        // 源对象类型来自定义。此前是 rule.SourceObjectType ?? rule.WatchObjectType 两列回落 ——
+        // 两列都是用户可写的,而该值直接进 dedup_key,改动即造成历史异常断代。
+        var sourceObjectType = definition.SourceObjectType;
+
+        // 判定列来自定义。Provider 按 canonical 名产出行,因此这些值就是 canonical 名;
+        // 若结果集确实没有该列,读取自然得到 null → 该行不命中,不再退回"某个 JSON 里写的列名"。
+        var statusColumn = semantics.StatusColumn;
+        var dueAtColumn = semantics.DueAtColumn;
+        var objectCodeColumn = semantics.RelatedObjectCodeColumn;
+        var objectIdColumn = semantics.SourceObjectIdColumn;
 
         foreach (var row in rowSet.Rows)
         {
             var status = row.GetString(statusColumn) ?? string.Empty;
-            if (parameters.CompletedStates.Contains(status, StringComparer.OrdinalIgnoreCase))
+            if (semantics.CompletedStates.Contains(status, StringComparer.OrdinalIgnoreCase))
                 continue;
 
             var due = row.GetDateTime(dueAtColumn);
@@ -111,24 +137,24 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
 
             var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
 
-            var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
+            var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
             hits.Add(new S8RuleHit
             {
                 SourceRuleId = rule.Id,
-                SourceRuleCode = rule.RuleCode,
+                SourceRuleCode = definition.RuleCode,
                 SourceObjectType = sourceObjectType,
                 SourceObjectId = sourceObjectId,
                 RelatedObjectCode = relatedObjectCode,
-                ExceptionTypeCode = parameters.ExceptionTypeCode!,
-                SceneCode = rule.SceneCode,
-                Severity = S8SeverityCode.Normalize(rule.Severity),
+                ExceptionTypeCode = definition.ExceptionTypeCode,
+                SceneCode = definition.SceneCode,
+                Severity = parameters.Severity,
                 DedupKey = dedupKey,
-                SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, due.Value, status, parameters),
+                SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, due.Value, status, definition, parameters),
                 DetectedAt = detectedAt,
                 Title = $"[超时] {sourceObjectType} {sourceObjectId} 已超期至 {due.Value:yyyy-MM-dd HH:mm:ss}(状态 {status})",
-                OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId),
-                ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId)
+                OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId) ?? parameters.DefaultOccurrenceDeptId,
+                ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId) ?? parameters.DefaultResponsibleDeptId
             });
         }
 
@@ -139,7 +165,9 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
     internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
         $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
 
-    private static string BuildPayload(S8MonitoringRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status, S8TimeoutParams parameters)
+    private static string BuildPayload(
+        S8MonitoringRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status,
+        Definitions.S8RuleDefinition definition, S8RuleRuntimeParameters parameters)
     {
         var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
         foreach (var kv in row.Values)
@@ -150,18 +178,7 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         payload["__dueAt"] = dueAt;
         payload["__status"] = status;
         payload["__graceMinutes"] = parameters.GraceMinutes;
-        payload["__exceptionTypeCode"] = parameters.ExceptionTypeCode;
+        payload["__exceptionTypeCode"] = definition.ExceptionTypeCode;
         return JsonSerializer.Serialize(payload);
     }
-
-    /// <summary>
-    /// 结果列名解析:BuildExpression 已把结果列统一别名为 canonical(due_at/status/source_object_id/
-    /// related_object_code)。优先返回 canonical 列名;仅当结果集不含 canonical 列时,回退到 params_json
-    /// 指定的真实列名(兼容历史未别名规则)。仅在 canonical 与 params 字段之间二选一,不新增无依据兜底字段。
-    /// </summary>
-    private static string ResolveResultColumn(S8MonitoringRowSet rowSet, string canonicalColumn, string? paramsColumn)
-    {
-        if (rowSet.HasColumn(canonicalColumn)) return canonicalColumn;
-        return string.IsNullOrWhiteSpace(paramsColumn) ? canonicalColumn : paramsColumn!;
-    }
 }

+ 143 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RuleParametersPayload.cs

@@ -0,0 +1,143 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8-RULE-GOVERNANCE-BATCH1:监控规则**运行参数**的部分更新载荷。
+///
+/// <para><b>PATCH 语义(G1 的直接修复)</b>:每个字段都是可空的,
+/// <b>null / 未提供 = 保持原值</b>。旧的 <c>S8WatchRuleParamsPayload</c> 用的是
+/// "整块覆盖 + 缺字段即置 NULL",于是一次 <c>{"enabled":false}</c> 就把 params_json 抹成 NULL,
+/// 规则的全部业务配置随之消失(本地 Rule 01 已实测发生:13:34 还在正常命中,
+/// 13:40 一次关开关之后 params_json = NULL)。
+/// 这里绝不再依赖"前端每次把整块数据带回来"这种客户端自觉。</para>
+///
+/// <para><b>本批不支持"清空"语义</b>:两个部门兜底字段传 null 表示"不改",
+/// 而不是"清空"。要清空需要能区分「没传」与「显式传 null」,
+/// 而 MVC 这里走的是 Newtonsoft 管线,可靠地做出这个区分需要额外的 Optional 包装 +
+/// 自定义转换器。本批不引入 —— 清空能力没有当前需求,为它加一层反序列化魔法
+/// 反而制造新的静默失败面。缺口已登记,需要时单独做。</para>
+///
+/// <para><b>Definition 字段一律拒绝,而不是忽略</b>:见 <see cref="RejectedDefinitionFields"/>。
+/// 静默忽略会让调用方以为改成功了 —— 那是比报错更糟的失败形态。</para>
+/// </summary>
+public sealed class S8RuleParametersPayload
+{
+    // ───────────────── 白名单:真正的运行策略 ─────────────────
+
+    /// <summary>轮询间隔(秒)。取值域由该规则的 Definition 声明。</summary>
+    public int? PollIntervalSeconds { get; set; }
+
+    /// <summary>连续命中 N 次才建单。</summary>
+    public int? TriggerCountRequired { get; set; }
+
+    /// <summary>连续未命中 N 次才标 recovered。</summary>
+    public int? RecoverCountRequired { get; set; }
+
+    /// <summary>宽限分钟。</summary>
+    public int? GraceMinutes { get; set; }
+
+    /// <summary>严重度。必须在 Definition 的 AllowedSeverities 内。</summary>
+    public string? Severity { get; set; }
+
+    /// <summary>发生部门兜底。</summary>
+    public long? DefaultOccurrenceDeptId { get; set; }
+
+    /// <summary>责任部门兜底。</summary>
+    public long? DefaultResponsibleDeptId { get; set; }
+
+    // ───────────────── 毒丸:出现即拒绝 ─────────────────
+    //
+    // 这些属性存在的**唯一**目的是让旧客户端(或试图绕过治理的调用)得到明确的 400,
+    // 而不是一个"成功但什么都没发生"的 200。
+    //
+    // 为什么用显式属性而不是依赖序列化器的 unknown-member 拒绝:
+    // 本仓 MVC 走 AddNewtonsoftJson(Admin.NET.Web.Core/Startup.cs),
+    // System.Text.Json 的 [JsonUnmappedMemberHandling] 在此不生效;
+    // Newtonsoft 的 MissingMemberHandling 又是全局设置,改它会外溢到 S0–S7 所有接口。
+    // 声明毒丸属性是唯一既精确、又不依赖序列化器配置的做法。
+
+    /// <summary>已退役。params_json 不再由本接口整块写入。</summary>
+    public string? ParamsJson { get; set; }
+
+    /// <summary>已退役。启停走 /enable 与 /disable,见 <c>AdoS8ConfigWatchRulesController</c>。</summary>
+    public bool? Enabled { get; set; }
+
+    /// <summary>已退役:由代码定义。</summary>
+    public string? DatasetCode { get; set; }
+
+    /// <summary>已退役:由代码定义。</summary>
+    public string? RuleType { get; set; }
+
+    /// <summary>已退役:由代码定义。</summary>
+    public string? SourceObjectType { get; set; }
+
+    /// <summary>已退役:由代码定义。</summary>
+    public string? ExceptionTypeCode { get; set; }
+
+    /// <summary>已退役:由代码定义。</summary>
+    public string? SceneCode { get; set; }
+
+    /// <summary>已退役:由代码定义(原可经 /params 修改)。</summary>
+    public string? StageCode { get; set; }
+
+    /// <summary>已退役:由代码定义(原可经 /params 修改)。</summary>
+    public string? OrderFlowCode { get; set; }
+
+    /// <summary>已退役:由代码定义(原可经 /params 修改)。</summary>
+    public string? RuleMechanism { get; set; }
+
+    /// <summary>已退役:由代码定义。</summary>
+    public string? DueAtField { get; set; }
+
+    /// <summary>已退役:由代码定义。</summary>
+    public string? StatusField { get; set; }
+
+    /// <summary>已退役:由代码定义(它决定去重身份)。</summary>
+    public string? ObjectIdField { get; set; }
+
+    /// <summary>已退役:由代码定义。</summary>
+    public string? ObjectCodeField { get; set; }
+
+    /// <summary>已退役:由代码定义。</summary>
+    public string[]? CompletedStates { get; set; }
+
+    /// <summary>
+    /// 本次请求中出现的、已退役的 Definition 字段名。为空表示载荷合法。
+    /// </summary>
+    public IReadOnlyList<string> RejectedDefinitionFields
+    {
+        get
+        {
+            var found = new List<string>();
+            void Check(string name, bool present) { if (present) found.Add(name); }
+
+            Check("paramsJson", ParamsJson != null);
+            Check("enabled", Enabled.HasValue);
+            Check("datasetCode", DatasetCode != null);
+            Check("ruleType", RuleType != null);
+            Check("sourceObjectType", SourceObjectType != null);
+            Check("exceptionTypeCode", ExceptionTypeCode != null);
+            Check("sceneCode", SceneCode != null);
+            Check("stageCode", StageCode != null);
+            Check("orderFlowCode", OrderFlowCode != null);
+            Check("ruleMechanism", RuleMechanism != null);
+            Check("dueAtField", DueAtField != null);
+            Check("statusField", StatusField != null);
+            Check("objectIdField", ObjectIdField != null);
+            Check("objectCodeField", ObjectCodeField != null);
+            Check("completedStates", CompletedStates != null);
+            return found;
+        }
+    }
+}
+
+/// <summary>
+/// S8-SCHED-FRONTEND-1:调度参数安全更新载荷(保留兼容)。
+/// 三个字段现已并入 <see cref="S8RuleParametersPayload"/>,本类型仅供既有
+/// <c>PUT /{id}/schedule</c> 路由继续工作;服务端实现已委托到同一条参数写入路径。
+/// </summary>
+public sealed class S8WatchRuleSchedulePayload
+{
+    public int PollIntervalSeconds { get; set; } = 300;
+    public int TriggerCountRequired { get; set; } = 1;
+    public int RecoverCountRequired { get; set; } = 1;
+}

+ 0 - 39
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleParamsPayload.cs

@@ -1,39 +0,0 @@
-namespace Admin.NET.Plugin.AiDOP.Service.S8;
-
-/// <summary>
-/// R4 监视规则配置 UI 安全更新载荷。
-/// 仅承载白名单字段;expression / rule_code / data_source_id / scene_code / watch_object_type
-/// 不在该接口暴露,业务用户无法通过此路径修改 SQL 表达式或绑定关系。
-/// </summary>
-public sealed class S8WatchRuleParamsPayload
-{
-    /// <summary>params_json,由服务端按 RuleType 校验是否可被对应 evaluator 解析。</summary>
-    public string? ParamsJson { get; set; }
-
-    /// <summary>启用 / 停用。</summary>
-    public bool Enabled { get; set; }
-
-    // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度归属(S_STAGE / ORDER_FLOW)+ 报警机制。
-    // 这里允许手选改写;保存时按字段原样落库(含 null 清空),前端每次保存需带回当前快照。
-
-    /// <summary>S_STAGE 维度节点(S1-S7)。</summary>
-    public string? StageCode { get; set; }
-
-    /// <summary>ORDER_FLOW 维度节点;可空。</summary>
-    public string? OrderFlowCode { get; set; }
-
-    /// <summary>报警机制:MANUAL_REPORT / DATE / RATIO / VALUE_RANGE。</summary>
-    public string? RuleMechanism { get; set; }
-}
-
-/// <summary>
-/// S8-SCHED-FRONTEND-1:调度参数安全更新载荷。
-/// 仅承载 poll_interval_seconds / trigger_count_required / recover_count_required 三字段;
-/// 其它运行态字段(next_run_at / last_run_at / lock_*)不通过此接口修改。
-/// </summary>
-public sealed class S8WatchRuleSchedulePayload
-{
-    public int PollIntervalSeconds { get; set; } = 300;
-    public int TriggerCountRequired { get; set; } = 1;
-    public int RecoverCountRequired { get; set; } = 1;
-}

+ 10 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs

@@ -2,6 +2,7 @@ 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;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -34,6 +35,7 @@ public class S8WatchRulePreviewService : ITransient
     private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
     private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
     private readonly S8MonitoringDataGateway _gateway;
+    private readonly IS8RuleCatalog _ruleCatalog;
     private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
     private readonly S8ShortageRuleEvaluator _shortageEvaluator;
     private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
@@ -42,6 +44,7 @@ public class S8WatchRulePreviewService : ITransient
         SqlSugarRepository<AdoS8WatchRule> ruleRep,
         SqlSugarRepository<AdoS8Exception> exceptionRep,
         S8MonitoringDataGateway gateway,
+        IS8RuleCatalog ruleCatalog,
         S8TimeoutRuleEvaluator timeoutEvaluator,
         S8ShortageRuleEvaluator shortageEvaluator,
         S8OutOfRangeRuleEvaluator outOfRangeEvaluator)
@@ -49,6 +52,7 @@ public class S8WatchRulePreviewService : ITransient
         _ruleRep = ruleRep;
         _exceptionRep = exceptionRep;
         _gateway = gateway;
+        _ruleCatalog = ruleCatalog;
         _timeoutEvaluator = timeoutEvaluator;
         _shortageEvaluator = shortageEvaluator;
         _outOfRangeEvaluator = outOfRangeEvaluator;
@@ -64,9 +68,11 @@ public class S8WatchRulePreviewService : ITransient
             .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,无法预演");
+        // S8-RULE-GOVERNANCE-BATCH1:预演必须与真实运行走同一份语义来源。
+        // 若这里读 rule.RuleType(DB 投影列)而 evaluator 读定义,两者一旦不一致,
+        // 预演结果就会与真跑结果不同 —— 而预演的全部价值就建立在"两者相同"上。
+        var definition = _ruleCatalog.GetRequired(rule.RuleCode);
+        var ruleType = definition.RuleType;
 
         IS8RuleEvaluator evaluator = ruleType switch
         {
@@ -103,7 +109,7 @@ public class S8WatchRulePreviewService : ITransient
             RuleId = rule.Id,
             RuleCode = rule.RuleCode,
             RuleType = ruleType,
-            DatasetCode = rule.DatasetCode,
+            DatasetCode = definition.DatasetCode,
             Enabled = rule.Enabled,
             TenantId = scope.TenantId,
             FactoryId = scope.FactoryId,

+ 196 - 94
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs

@@ -4,6 +4,7 @@ using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -13,17 +14,20 @@ public class S8WatchRuleService : ITransient
     private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
     private readonly IS8DatasetCatalog _datasetCatalog;
     private readonly S8DatasetEnableGate _datasetEnableGate;
+    private readonly IS8RuleCatalog _ruleCatalog;
 
     public S8WatchRuleService(
         SqlSugarRepository<AdoS8WatchRule> rep,
         SqlSugarRepository<AdoS8SceneConfig> sceneRep,
         IS8DatasetCatalog datasetCatalog,
-        S8DatasetEnableGate datasetEnableGate)
+        S8DatasetEnableGate datasetEnableGate,
+        IS8RuleCatalog ruleCatalog)
     {
         _rep = rep;
         _sceneRep = sceneRep;
         _datasetCatalog = datasetCatalog;
         _datasetEnableGate = datasetEnableGate;
+        _ruleCatalog = ruleCatalog;
     }
 
     public async Task<List<AdoS8WatchRule>> ListAsync(long tenantId, long factoryId) =>
@@ -44,7 +48,7 @@ public class S8WatchRuleService : ITransient
         // S8-STEP6E:新建一律走 canonical 词表 + params schema,杜绝「存得下但运行时永远不生效」。
         ValidateVocabularyForCreate(body);
         if (!string.IsNullOrWhiteSpace(body.ParamsJson))
-            ValidateParamsJsonByRuleType(body.RuleType, body.ParamsJson!.Trim());
+            ValidateParamsJsonShape(body.ParamsJson!.Trim());
         body.Id = 0;
         body.CreatedAt = DateTime.Now;
         // S8-STEP6E(与 CFG_DATASRC D-3 / CFG_ROLES 同源缺陷):回填自增主键。
@@ -100,43 +104,177 @@ public class S8WatchRuleService : ITransient
             .FirstAsync() ?? throw new S8NotFoundException();
 
     /// <summary>
-    /// R4 安全更新:只更新 params_json 与 enabled。expression / rule_code / data_source_id /
-    /// scene_code / watch_object_type / rule_type / source_object_type 一律不通过此路径修改。
-    /// 当 RuleType 非空时,按对应 evaluator 的 Params.Parse 进行 schema 校验,解析失败抛 S8BizException。
+    /// S8-RULE-GOVERNANCE-BATCH1:运行参数的**部分更新**。
+    ///
+    /// <para>与被它取代的 <c>UpdateParamsAsync</c> 的三处根本差异:</para>
+    /// <list type="number">
+    ///   <item><b>不再接受 params_json 原文</b>。判定语义(dueAtField / statusField /
+    ///         completedStates / objectIdField / exceptionTypeCode)现在只存在于代码定义里,
+    ///         没有任何 API 能改到它们;</item>
+    ///   <item><b>不再承担启停</b>。enabled 走 <see cref="EnableAsync"/> / <see cref="DisableAsync"/>;</item>
+    ///   <item><b>PATCH 而非整块覆盖</b>。未提供的字段保持原值 —— 这是 G1 的直接修复:
+    ///         旧实现里一次 <c>{"enabled":false}</c> 就会把 params_json 抹成 NULL。</item>
+    /// </list>
+    ///
+    /// <para>写入用 <c>UpdateColumns</c> 白名单,物理上无法触碰 Definition 投影列与 13 个调度运行态列。</para>
     /// </summary>
-    public async Task<AdoS8WatchRule> UpdateParamsAsync(long id, S8WatchRuleParamsPayload payload, S8TrustedScope scope)
+    public async Task<AdoS8WatchRule> UpdateParametersAsync(long id, S8RuleParametersPayload payload, S8TrustedScope scope)
     {
+        // 前置拒绝:在任何 DB 访问之前判掉非法载荷,越权 id 也不会被用来探测记录是否存在。
+        // 同一道守卫在 ApplyParameters 内再做一次 —— 那里才是所有调用方的必经之处。
+        EnsureNoDefinitionFields(payload);
+
         var entity = await LoadScopedAsync(id, scope);
+        var definition = _ruleCatalog.GetRequired(entity.RuleCode);
+
+        var next = ApplyParameters(entity, payload, definition);
+
+        await _rep.Context.Updateable<AdoS8WatchRule>()
+            .SetColumns(x => new AdoS8WatchRule
+            {
+                PollIntervalSeconds = next.PollIntervalSeconds,
+                TriggerCountRequired = next.TriggerCountRequired,
+                RecoverCountRequired = next.RecoverCountRequired,
+                Severity = next.Severity,
+                ParamsJson = next.ToParamsJson(),
+                UpdatedAt = DateTime.Now
+            })
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .ExecuteCommandAsync();
+
+        return await LoadScopedAsync(id, scope);
+    }
+
+    /// <summary>
+    /// 纯函数:把 PATCH 载荷叠加到「当前生效参数」上,并按 Definition 声明的取值域校验。
+    ///
+    /// <para>抽成 static 是为了让 G1 的核心不变量(未提供 = 不变)能在**不接数据库**的情况下被测试
+    /// 逐字段断言。之前那条缺陷之所以能活到生产,正是因为它藏在一个必须有仓储才能跑的方法里。</para>
+    /// </summary>
+    internal static S8RuleRuntimeParameters ApplyParameters(
+        AdoS8WatchRule entity, S8RuleParametersPayload payload, S8RuleDefinition definition)
+    {
+        EnsureNoDefinitionFields(payload);
+
+        var current = S8RuleRuntimeParameters.Resolve(entity, definition);
+        var policy = definition.Parameters ?? new S8RuleParameterPolicy();
+
+        var poll = payload.PollIntervalSeconds ?? current.PollIntervalSeconds;
+        var trigger = payload.TriggerCountRequired ?? current.TriggerCountRequired;
+        var recover = payload.RecoverCountRequired ?? current.RecoverCountRequired;
+        var grace = payload.GraceMinutes ?? current.GraceMinutes;
+        var severity = payload.Severity?.Trim() ?? current.Severity;
+
+        EnsureInRange("轮询间隔(秒)", poll, policy.PollIntervalSecondsMin, policy.PollIntervalSecondsMax);
+        EnsureInRange("连续命中建单次数", trigger, policy.TriggerCountRequiredMin, policy.TriggerCountRequiredMax);
+        EnsureInRange("连续未命中恢复次数", recover, policy.RecoverCountRequiredMin, policy.RecoverCountRequiredMax);
+        EnsureInRange("宽限分钟", grace, policy.GraceMinutesMin, policy.GraceMinutesMax);
+
+        if (!policy.AllowedSeverities.Contains(severity, StringComparer.Ordinal))
+            throw new S8BizException(
+                $"不支持的严重度:{severity};当前规则仅支持 {string.Join(" / ", policy.AllowedSeverities)}");
+
+        var occurrenceDept = payload.DefaultOccurrenceDeptId ?? current.DefaultOccurrenceDeptId;
+        var responsibleDept = payload.DefaultResponsibleDeptId ?? current.DefaultResponsibleDeptId;
+        if (!policy.AllowsDepartmentDefaults && (occurrenceDept.HasValue || responsibleDept.HasValue))
+            throw new S8BizException("当前规则不支持配置部门兜底");
 
-        var paramsJson = payload.ParamsJson?.Trim();
-        if (!string.IsNullOrEmpty(paramsJson))
+        return new S8RuleRuntimeParameters
         {
-            ValidateParamsJsonByRuleType(entity.RuleType, paramsJson);
-        }
+            PollIntervalSeconds = poll,
+            TriggerCountRequired = trigger,
+            RecoverCountRequired = recover,
+            Severity = severity,
+            GraceMinutes = grace,
+            DefaultOccurrenceDeptId = occurrenceDept,
+            DefaultResponsibleDeptId = responsibleDept
+        };
+    }
+
+    /// <summary>
+    /// 载荷里出现 Definition 字段(或已迁走的 enabled)一律**显式拒绝**。
+    ///
+    /// <para>静默忽略等于告诉调用方"改成功了",而实际什么都没发生 —— 那比报错更危险,
+    /// 因为调用方会据此认为规则已经按新口径运行。</para>
+    ///
+    /// <para>放在 <see cref="ApplyParameters"/> 内而不是只放在 API 层:
+    /// 纯函数是所有写入路径的必经之处,守卫挂在这里才不会被下一个调用方绕过。</para>
+    /// </summary>
+    private static void EnsureNoDefinitionFields(S8RuleParametersPayload payload)
+    {
+        if (payload == null) throw new S8BizException("请求体不能为空");
+
+        var rejected = payload.RejectedDefinitionFields;
+        if (rejected.Count > 0)
+            throw new S8BizException(
+                "以下字段由代码定义,不能通过参数接口修改:" + string.Join(" / ", rejected)
+                + ";启停请使用 /enable 与 /disable");
+    }
+
+    private static void EnsureInRange(string label, int value, int min, int max)
+    {
+        if (value < min || value > max)
+            throw new S8BizException($"{label} 必须在 {min}–{max} 之间,当前值 {value}");
+    }
+
+    /// <summary>
+    /// 启用规则。<b>只写 enabled 与调度触发时间,绝不触碰任何参数列或 Definition 投影列。</b>
+    ///
+    /// <para>幂等:已启用时直接返回,不产生写入 —— 重复调用不会重排下次执行时间,
+    /// 也就不会被用来变相"插队"调度。</para>
+    /// </summary>
+    public async Task<AdoS8WatchRule> EnableAsync(long id, S8TrustedScope scope)
+    {
+        var entity = await LoadScopedAsync(id, scope);
+        if (entity.Enabled) return entity;
+
+        // ① 没有代码定义的规则不得启用。这是 Create API 仍然存在期间的安全过渡:
+        //    业务即使造出一条任意 rule_code 的规则,也无法让调度器替它跑。
+        var definition = _ruleCatalog.GetRequired(entity.RuleCode);
+
+        // ② 数据集侧完整运行条件。按 Definition 的 dataset_code / rule_type 判定,
+        //    而不是 DB 上那两列 —— 后者是投影,可能被人为改过。
+        _datasetEnableGate.EnsureCanEnable(
+            definition.DatasetCode, definition.RuleType, definition.RuleCode, scope.TenantId, scope.FactoryId);
 
-        entity.ParamsJson = string.IsNullOrEmpty(paramsJson) ? null : paramsJson;
-
-        // S8-DATASET-FOUNDATION-HARDENING-2:本方法是规则 enabled 的唯一切换入口,
-        // 因此 Enable Gate 挂在此处。由 disabled → enabled 时必须满足完整运行条件;
-        // 关闭规则不受 Gate 约束(否则数据集出问题后规则将无法被关停)。
-        if (payload.Enabled && !entity.Enabled)
-            S8WatchRuleDataAccessValidator.ValidateForEnable(entity, _datasetEnableGate, scope.TenantId, scope.FactoryId);
-
-        entity.Enabled = payload.Enabled;
-        // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度归属 + 报警机制按 payload 原样落库(含 null 清空)。
-        entity.StageCode = NormalizeOrNull(payload.StageCode);
-        entity.OrderFlowCode = NormalizeOrNull(payload.OrderFlowCode);
-        entity.RuleMechanism = NormalizeOrNull(payload.RuleMechanism);
-        entity.UpdatedAt = DateTime.Now;
-        await _rep.UpdateAsync(entity);
-        return entity;
+        var now = DateTime.Now;
+        await _rep.Context.Updateable<AdoS8WatchRule>()
+            .SetColumns(x => new AdoS8WatchRule
+            {
+                Enabled = true,
+                NextRunAt = now,
+                UpdatedAt = now
+            })
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .ExecuteCommandAsync();
+
+        return await LoadScopedAsync(id, scope);
     }
 
-    private static string? NormalizeOrNull(string? value)
+    /// <summary>
+    /// 停用规则。<b>只写 enabled。</b>
+    ///
+    /// <para>幂等:已停用时直接返回。</para>
+    /// <para>不动 lease / next_run_at:<c>PickReadyRulesAsync</c> 的候选谓词第一条就是
+    /// <c>x.Enabled</c>,停用后自然不会再被拾取;正在执行中的那一轮由
+    /// <c>ResetExpiredLeasesAsync</c> 按既有租约语义收尾。强行清租约反而会与正在跑的实例撕扯。</para>
+    /// </summary>
+    public async Task<AdoS8WatchRule> DisableAsync(long id, S8TrustedScope scope)
     {
-        if (string.IsNullOrWhiteSpace(value)) return null;
-        var trimmed = value.Trim();
-        return trimmed.Length == 0 ? null : trimmed;
+        var entity = await LoadScopedAsync(id, scope);
+        if (!entity.Enabled) return entity;
+
+        // 停用**不过** Enable Gate:数据集出问题之后仍然必须能把规则关掉。
+        await _rep.Context.Updateable<AdoS8WatchRule>()
+            .SetColumns(x => new AdoS8WatchRule
+            {
+                Enabled = false,
+                UpdatedAt = DateTime.Now
+            })
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .ExecuteCommandAsync();
+
+        return await LoadScopedAsync(id, scope);
     }
 
     /// <summary>
@@ -188,47 +326,20 @@ public class S8WatchRuleService : ITransient
                 + S8SeverityCode.Follow + " / " + S8SeverityCode.Serious);
     }
 
-    private static void ValidateParamsJsonByRuleType(string? ruleType, string paramsJson)
+    /// <summary>
+    /// S8-RULE-GOVERNANCE-BATCH1:params_json 的按类型 schema 校验已退役。
+    ///
+    /// <para>它校验的是 <c>dueAtField</c> / <c>statusField</c> / <c>exceptionTypeCode</c> 这些
+    /// **A 类判定语义**是否齐备 —— 而这些字段现在只存在于代码定义里,params_json 根本不再承载它们。
+    /// 继续校验等于要求调用方提交一份已经没有意义的 JSON。</para>
+    ///
+    /// <para>保留 JSON 合法性检查:脏文本存进去会让运行期解析失败,
+    /// 虽然 <see cref="S8RuleRuntimeParameters.Resolve"/> 会回落默认值而不报错,
+    /// 但让一份读不懂的文本静静躺在库里没有任何好处。</para>
+    /// </summary>
+    private static void ValidateParamsJsonShape(string paramsJson)
     {
-        try
-        {
-            switch (ruleType)
-            {
-                case S8TimeoutRuleEvaluator.RuleTypeCode:
-                    {
-                        var p = S8TimeoutParams.Parse(paramsJson);
-                        if (string.IsNullOrWhiteSpace(p.DueAtField)
-                            || string.IsNullOrWhiteSpace(p.StatusField)
-                            || string.IsNullOrWhiteSpace(p.ExceptionTypeCode))
-                            throw new S8BizException("TIMEOUT params 缺少必填字段:dueAtField / statusField / exceptionTypeCode");
-                        break;
-                    }
-                case S8ShortageRuleEvaluator.RuleTypeCode:
-                    {
-                        var p = S8ShortageParams.Parse(paramsJson);
-                        if (string.IsNullOrWhiteSpace(p.TargetQtyField)
-                            || string.IsNullOrWhiteSpace(p.ActualQtyField)
-                            || string.IsNullOrWhiteSpace(p.ExceptionTypeCode))
-                            throw new S8BizException("SHORTAGE params 缺少必填字段:targetQtyField / actualQtyField / exceptionTypeCode");
-                        break;
-                    }
-                case S8OutOfRangeRuleEvaluator.RuleTypeCode:
-                    {
-                        var p = S8OutOfRangeParams.Parse(paramsJson);
-                        if (string.IsNullOrWhiteSpace(p.MeasuredValueField))
-                            throw new S8BizException("OUT_OF_RANGE params 缺少必填字段:measuredValueField");
-                        if (p.LowerBound == null && p.UpperBound == null
-                            && string.IsNullOrWhiteSpace(p.LowerBoundField)
-                            && string.IsNullOrWhiteSpace(p.UpperBoundField))
-                            throw new S8BizException("OUT_OF_RANGE params 必须提供 upperBound / lowerBound 或对应行内字段之一");
-                        break;
-                    }
-                default:
-                    // RuleType 为空或非三类已知值:仅做 JSON 合法性校验,避免阻塞历史数据。
-                    using (JsonDocument.Parse(paramsJson)) { }
-                    break;
-            }
-        }
+        try { using (JsonDocument.Parse(paramsJson)) { } }
         catch (JsonException ex)
         {
             throw new S8BizException($"params_json 不是合法 JSON:{ex.Message}");
@@ -249,29 +360,15 @@ public class S8WatchRuleService : ITransient
     /// S8-SCHED-FRONTEND-1:调度参数安全更新。仅修改 poll_interval_seconds / trigger_count_required /
     /// recover_count_required;不动 params_json / expression / rule_type / scene_code / data_source_id。
     /// </summary>
-    public async Task<AdoS8WatchRule> UpdateScheduleAsync(long id, S8WatchRuleSchedulePayload payload, S8TrustedScope scope)
-    {
-        var entity = await LoadScopedAsync(id, scope);
-
-        if (payload.PollIntervalSeconds < 60 || payload.PollIntervalSeconds > 86400)
-            throw new S8BizException("poll_interval_seconds 必须在 60–86400 之间");
-        if (payload.TriggerCountRequired < 1 || payload.TriggerCountRequired > 10)
-            throw new S8BizException("trigger_count_required 必须在 1–10 之间");
-        if (payload.RecoverCountRequired < 1 || payload.RecoverCountRequired > 10)
-            throw new S8BizException("recover_count_required 必须在 1–10 之间");
-
-        await _rep.Context.Updateable<AdoS8WatchRule>()
-            .SetColumns(x => new AdoS8WatchRule
-            {
-                PollIntervalSeconds = payload.PollIntervalSeconds,
-                TriggerCountRequired = payload.TriggerCountRequired,
-                RecoverCountRequired = payload.RecoverCountRequired,
-                UpdatedAt = DateTime.Now
-            })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
-            .ExecuteCommandAsync();
-        return await LoadScopedAsync(id, scope);
-    }
+    public Task<AdoS8WatchRule> UpdateScheduleAsync(long id, S8WatchRuleSchedulePayload payload, S8TrustedScope scope) =>
+        // S8-RULE-GOVERNANCE-BATCH1:三个调度字段已并入统一参数白名单,本入口只做形状转换后委托,
+        // 不再各自维护一份取值域 —— 两处取值域一旦漂移,就会出现「A 接口存得下、B 接口存不下」。
+        UpdateParametersAsync(id, new S8RuleParametersPayload
+        {
+            PollIntervalSeconds = payload?.PollIntervalSeconds,
+            TriggerCountRequired = payload?.TriggerCountRequired,
+            RecoverCountRequired = payload?.RecoverCountRequired
+        }, scope);
 
     /// <summary>
     /// S8-SCHED-FRONTEND-1:立即执行一次。把 next_run_at 置为 NOW,让下个 tick 拾取。
@@ -355,8 +452,13 @@ public class S8WatchRuleService : ITransient
         S8WatchRuleDataAccessValidator.ValidateForSave(body, _datasetCatalog);
 
         // 只有真正要启用时才要求完整运行条件;草稿 / disabled 规则允许 Provider 未上线。
+        // S8-RULE-GOVERNANCE-BATCH1:启用条件按**代码定义**判定,不按 body 上的投影列。
         if (body.Enabled)
-            S8WatchRuleDataAccessValidator.ValidateForEnable(body, _datasetEnableGate, scope.TenantId, scope.FactoryId);
+        {
+            var definition = _ruleCatalog.GetRequired(body.RuleCode);
+            _datasetEnableGate.EnsureCanEnable(
+                definition.DatasetCode, definition.RuleType, definition.RuleCode, scope.TenantId, scope.FactoryId);
+        }
 
         var scene = await _sceneRep.GetFirstAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.SceneCode == body.SceneCode)
             ?? throw new S8BizException("关联场景不存在");

+ 26 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -2,6 +2,7 @@ using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
 using Microsoft.Extensions.Logging;
 using SqlSugar;
 using System.Globalization;
@@ -32,6 +33,7 @@ public class S8WatchSchedulerService : ITransient
     private readonly ILogger<S8WatchSchedulerService> _logger;
     private readonly SqlSugarRepository<AdoS8DetectionLog> _detectionLogRep;
     private readonly SqlSugarRepository<AdoS8RuleDetectionState> _detectionStateRep;
+    private readonly IS8RuleCatalog _ruleCatalog;
 
     private const string DetectionTriggerSource = "WATCH_SCHEDULER";
     private const string DetectResultCreated = "CREATED";
@@ -68,7 +70,8 @@ public class S8WatchSchedulerService : ITransient
         S8OutOfRangeRuleEvaluator outOfRangeEvaluator,
         ILogger<S8WatchSchedulerService> logger,
         SqlSugarRepository<AdoS8DetectionLog> detectionLogRep,
-        SqlSugarRepository<AdoS8RuleDetectionState> detectionStateRep)
+        SqlSugarRepository<AdoS8RuleDetectionState> detectionStateRep,
+        IS8RuleCatalog ruleCatalog)
     {
         _ruleRep = ruleRep;
         _exceptionRep = exceptionRep;
@@ -84,6 +87,7 @@ public class S8WatchSchedulerService : ITransient
         _logger = logger;
         _detectionLogRep = detectionLogRep;
         _detectionStateRep = detectionStateRep;
+        _ruleCatalog = ruleCatalog;
     }
 
     public async Task<List<S8TenantFactoryScope>> ListEnabledScopesAsync()
@@ -181,6 +185,15 @@ public class S8WatchSchedulerService : ITransient
 
         foreach (var rule in rules.OrderBy(x => x.Id))
         {
+            // S8-RULE-GOVERNANCE-BATCH1:无代码定义的规则不进入执行链(与 Job tick 路径同一口径)。
+            if (!_ruleCatalog.IsDefined(rule.RuleCode))
+            {
+                _logger.LogWarning(
+                    "run_once_skip_no_definition ruleId={RuleId} ruleCode={RuleCode}", rule.Id, rule.RuleCode);
+                aggregate.Add(BuildSkipResult(rule, S8RuleCatalog.ReasonNotFound, null));
+                continue;
+            }
+
             // S8-RUN-ONCE-LEASE-AWARENESS-1:debug run-once 不持 lease,但若该 rule 已被 Scheduler Job
             // 通过 PickReadyRulesAsync 抢锁(lock_token 非空且 lock_until > now),run-once 跳过该 rule,
             // 不写 last_*、不写 detection_log、不动锁,以避免与 Job tick 并发评估同一 rule 而互相覆盖运行态。
@@ -838,13 +851,21 @@ public class S8WatchSchedulerService : ITransient
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_scope_mismatch", Stats = new() };
         }
 
-        var ruleType = rule.RuleType;
-        if (string.IsNullOrWhiteSpace(ruleType))
+        // S8-RULE-GOVERNANCE-BATCH1:分派的真源是**代码定义**,不是 DB 上的 rule_type 列。
+        // 那一列现在只是 provisioning 维护的投影;若有人手工改它,规则的执行方式不应随之改变。
+        // 同时这里是「没有代码定义的规则不得进入执行链」的把关点 —— 在 Create API 仍存在的
+        // 过渡期(Batch 3 才退役),它保证业务即使造出任意 rule_code 也调不动调度器。
+        var definition = _ruleCatalog.TryGet(rule.RuleCode);
+        if (definition == null)
         {
-            // 未分类的旧规则不在新调度路径承载;标 SKIPPED 但不视为失败。
-            return new S8RuleRunResult { Success = true, ErrorMessage = "rule_type_empty_skipped", Stats = new() };
+            _logger.LogWarning(
+                "rule_definition_not_found ruleId={RuleId} ruleCode={RuleCode} tenant={Tenant}",
+                rule.Id, rule.RuleCode, tenantId);
+            return new S8RuleRunResult { Success = false, ErrorMessage = S8RuleCatalog.ReasonNotFound, Stats = new() };
         }
 
+        var ruleType = definition.RuleType;
+
         IS8RuleEvaluator? evaluator = ruleType switch
         {
             S8TimeoutRuleEvaluator.RuleTypeCode => _timeoutEvaluator,

+ 19 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -80,6 +80,15 @@ public class Startup : AppStartup
         // 两个接口都必须显式注册(同上,不依赖 ITransient 自动按接口注册)。
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8DatasetDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8BusinessDatasetDefinitions>();
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8MonitoringDataProvider, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8PurchaseDeliveryDataProvider>();
+
+        // S8-RULE-GOVERNANCE-BATCH1:规则定义目录。业务语义从此只存在于代码里,
+        // ado_s8_watch_rule 上的同名列降级为 provisioning 维护的只读投影。
+        //
+        // 注册为 **Singleton**(数据集目录是 Transient):目录构造期会做完整性校验,
+        // 而 Transient 的失败形态是「首次解析该服务的那个请求 500」,启动日志干净但目录其实是坏的。
+        // Singleton + Configure 里强制解析一次 = 定义写错就起不来,见下方 Configure。
+        services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8PurchaseDeliveryRuleDefinitions>();
+        services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleCatalog, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8RuleCatalog>();
     }
 
     /// <summary>
@@ -87,6 +96,16 @@ public class Startup : AppStartup
     /// </summary>
     public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
     {
+        // S8-RULE-GOVERNANCE-BATCH1:强制解析一次规则目录,把「定义写错」变成启动失败。
+        // 刻意**不 try/catch**:重复 rule_code、rule_type 不在词表、TIMEOUT 却没给判定语义,
+        // 都是发布事故。吞掉它只会让服务带着一个坏目录起来,然后在某条规则跑到那一 tick 时
+        // 才在后台日志里报错——那时页面上一切正常,没人会看见。
+        {
+            var ruleCatalog = app.ApplicationServices
+                .GetRequiredService<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleCatalog>();
+            Trace.TraceInformation($"Ai-DOP S8RuleCatalog: {ruleCatalog.Definitions.Count} rule definition(s) loaded");
+        }
+
         try
         {
             AidopMenuLinkSync.EnsureLinked(app.ApplicationServices);