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

refactor(s8): 拆分 Dataset Catalog 与 Provider Registry,建立统一 Enable Gate

Batch 1 把两件事合成了一件:"注册即目录"意味着没有 Provider 就等于数据集不存在,
无法表达"数据集已定义、实现尚未上线"这个必要的中间状态。本批拆开:

- Dataset Catalog(新)
  · S8DatasetDefinition + IS8DatasetDefinitionSource:数据集是什么(编码/名称/种类/能力/状态)
  · S8DatasetCatalog:聚合各来源,重复定义、非法 Kind/Status 一律启动即失败
  · S8DatasetKind:OBJECT(对象级)/ METRIC(指标级)
  · S8DatasetStatus:声明态 DEFINED/DISABLED 与有效态 NOT_DEFINED/PROVIDER_MISSING/READY 分层

- Capability 硬化为两层
  · 机制层 SupportsTimeout/Shortage/OutOfRange(沿用)
  · 字段层 HasSourceObjectId/HasRelatedObjectCode/HasDueAt/HasStatus/
    HasMeasuredValue/HasTargetQty/HasActualQty(新增)
  理由:单一布尔位不可信。声明 SupportsTimeout=true 却拿不出 due_at 是自相矛盾配置,
  运行期只会表现为"永不命中"的静默失效,两层交叉校验才能在启用前发现。

- S8RuleTypeFieldRequirements:RuleType→必需 canonical 字段的唯一权威定义,
  取自 evaluator 真实读取行为,杜绝各 service 复制 if (ruleType == ...)

- S8DatasetEnableGate:统一启用前校验,10 步顺序 + 精确 reason code
  校验顺序刻意"先目录后实现"——"这个数据集做不了 TIMEOUT"是定义问题,
  等 Provider 上线也不会变好;先报 provider_not_registered 会误导配置方

- 校验分两级:ValidateForSave(结构+目录归属,不查 Provider,允许存草稿)
  与 ValidateForEnable(完整运行条件)。Gate 挂在 Create 与 UpdateParamsAsync
  (规则 enabled 的唯一切换入口);关闭规则不受 Gate 约束,
  否则数据集出问题后规则将无法被关停

- 抽出 IS8LegacySqlDataProvider,使"STANDARD_DATASET 绝不回落 Legacy SQL"
  可被测试真实验证(注入探针断言调用次数为 0),而非只靠代码审阅

- Startup 显式注册 IS8DatasetCatalog / IS8LegacySqlDataProvider 接口映射
  (与既有 IModuleRebuildLock 同一先例:本项目不依赖 ITransient 自动按接口注册)

- S8DatasetDiagnosticsService:回答"哪些数据集当前可用于哪类规则",
  区分 READY / PROVIDER_MISSING / DISABLED,不预览任何业务数据

无 schema 变更(本批不需要,不为版本号强行加 migration)。
测试:新增 25 条,非 Selenium 用例 111/111 通过;Batch 1 evaluator 契约测试 23/23 未受影响。

本批仍不注册任何生产 Dataset/Provider,未迁移或启用任何业务规则,前端零改动。
YY968XX 2 дней назад
Родитель
Сommit
da24c9884b
18 измененных файлов с 1357 добавлено и 76 удалено
  1. 328 0
      server/Admin.NET.Test/S8/S8DatasetCatalogAndGateTests.cs
  2. 205 0
      server/Admin.NET.Test/S8/S8MonitoringDataGatewayTests.cs
  3. 106 31
      server/Admin.NET.Test/S8/S8WatchRuleDataAccessValidatorTests.cs
  4. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  5. 76 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetCapabilities.cs
  6. 70 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetCatalog.cs
  7. 45 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetDefinition.cs
  8. 103 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetDiagnosticsService.cs
  9. 144 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs
  10. 19 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetKind.cs
  11. 45 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetReasonCode.cs
  12. 36 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetStatus.cs
  13. 16 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8LegacySqlDataProvider.cs
  14. 50 15
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataGateway.cs
  15. 62 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8RuleTypeFieldRequirements.cs
  16. 23 13
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8WatchRuleDataAccessValidator.cs
  17. 19 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs
  18. 7 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

+ 328 - 0
server/Admin.NET.Test/S8/S8DatasetCatalogAndGateTests.cs

@@ -0,0 +1,328 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// S8-DATASET-FOUNDATION-HARDENING-2:Dataset Catalog 与 Provider Registry 解耦 + 统一 Enable Gate。
+///
+/// 本批要锁定的核心区分:
+///   · Dataset 根本不存在        → dataset_not_defined
+///   · Dataset 已定义但无 Provider → dataset_provider_not_registered(草稿可存,不可启用)
+///   · Dataset 声明支持某机制但缺必需字段 → dataset_missing_required_field(配置自相矛盾)
+///   · STANDARD_DATASET 取数失败绝不回落 Legacy SQL
+/// </summary>
+public class S8DatasetCatalogAndGateTests
+{
+    private const string DefinedWithProvider = "FAKE_TEST_DATASET_READY";
+    private const string DefinedWithoutProvider = "FAKE_TEST_DATASET_NO_PROVIDER";
+    private const string DefinedButDisabled = "FAKE_TEST_DATASET_DISABLED";
+    private const string ContradictoryDataset = "FAKE_TEST_DATASET_CONTRADICTORY";
+    private const string OutOfRangeOnlyDataset = "FAKE_TEST_DATASET_OOR_ONLY";
+
+    private static S8DatasetCapabilities TimeoutCapable() => new()
+    {
+        SupportsTimeout = true,
+        HasSourceObjectId = true,
+        HasRelatedObjectCode = true,
+        HasDueAt = true,
+        HasStatus = true
+    };
+
+    private sealed class FakeDefinitionSource : IS8DatasetDefinitionSource
+    {
+        public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
+        {
+            new S8DatasetDefinition
+            {
+                DatasetCode = DefinedWithProvider,
+                DisplayName = "测试用就绪数据集",
+                Kind = S8DatasetKind.Object,
+                Capabilities = TimeoutCapable()
+            },
+            new S8DatasetDefinition
+            {
+                DatasetCode = DefinedWithoutProvider,
+                DisplayName = "测试用未实现数据集",
+                Kind = S8DatasetKind.Object,
+                Capabilities = TimeoutCapable()
+            },
+            new S8DatasetDefinition
+            {
+                DatasetCode = DefinedButDisabled,
+                DisplayName = "测试用停用数据集",
+                Kind = S8DatasetKind.Metric,
+                Capabilities = TimeoutCapable(),
+                Status = S8DatasetStatus.Disabled
+            },
+            new S8DatasetDefinition
+            {
+                // 自相矛盾:声明支持 TIMEOUT,却没有 due_at 能力。
+                DatasetCode = ContradictoryDataset,
+                DisplayName = "测试用矛盾数据集",
+                Kind = S8DatasetKind.Object,
+                Capabilities = new S8DatasetCapabilities
+                {
+                    SupportsTimeout = true,
+                    HasSourceObjectId = true,
+                    HasRelatedObjectCode = true,
+                    HasDueAt = false,
+                    HasStatus = true
+                }
+            },
+            new S8DatasetDefinition
+            {
+                DatasetCode = OutOfRangeOnlyDataset,
+                DisplayName = "测试用仅超差数据集",
+                Kind = S8DatasetKind.Metric,
+                Capabilities = new S8DatasetCapabilities
+                {
+                    SupportsOutOfRange = true,
+                    HasSourceObjectId = true,
+                    HasRelatedObjectCode = true,
+                    HasMeasuredValue = true
+                }
+            }
+        };
+    }
+
+    private sealed class ReadyProvider : IS8MonitoringDataProvider
+    {
+        public string DatasetCode => DefinedWithProvider;
+        public S8DatasetCapabilities Capabilities => TimeoutCapable();
+        public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
+        {
+            request.EnsureValid();
+            return Task.FromResult(S8MonitoringRowSet.Empty);
+        }
+    }
+
+    private sealed class ThrowingProvider : IS8MonitoringDataProvider
+    {
+        public string DatasetCode => DefinedWithProvider;
+        public S8DatasetCapabilities Capabilities => TimeoutCapable();
+        public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
+            => throw new InvalidOperationException("provider blew up");
+    }
+
+    private static S8DatasetCatalog Catalog() => new(new[] { new FakeDefinitionSource() });
+    private static S8MonitoringDataProviderRegistry RegistryWithProvider() => new(new[] { new ReadyProvider() });
+    private static S8MonitoringDataProviderRegistry EmptyRegistry() =>
+        new(Array.Empty<IS8MonitoringDataProvider>());
+
+    private static AdoS8WatchRule StandardRule(string datasetCode, string ruleType = "TIMEOUT") => new()
+    {
+        RuleCode = "UT_RULE",
+        RuleType = ruleType,
+        DataAccessMode = S8DataAccessMode.StandardDataset,
+        DatasetCode = datasetCode
+    };
+
+    // ============================================================
+    // A. Catalog ≠ Provider Registry
+    // ============================================================
+
+    [Fact]
+    public void Catalog_KnowsDataset_EvenWhenProviderNotRegistered()
+    {
+        var catalog = Catalog();
+        Assert.True(catalog.IsDefined(DefinedWithoutProvider));
+        Assert.Equal("测试用未实现数据集", catalog.Find(DefinedWithoutProvider)!.DisplayName);
+        Assert.False(EmptyRegistry().IsRegistered(DefinedWithoutProvider));
+    }
+
+    [Fact]
+    public void DraftSave_Allowed_WhenDatasetDefinedButProviderMissing()
+    {
+        // 允许业务先把规则配好,等 Provider 上线再启用。
+        var rule = StandardRule(DefinedWithoutProvider);
+        S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog());
+    }
+
+    [Fact]
+    public void Enable_Rejected_WhenProviderMissing_WithProviderReasonCode()
+    {
+        var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
+        var result = gate.Check(StandardRule(DefinedWithoutProvider), tenantId: 1, factoryId: 1);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
+    }
+
+    // ============================================================
+    // B. Dataset 不存在,必须与 provider missing 区分
+    // ============================================================
+
+    [Fact]
+    public void UndefinedDataset_Reports_NotDefined_NotProviderMissing()
+    {
+        var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
+        var result = gate.Check(StandardRule("TOTALLY_UNKNOWN_DATASET"), 1, 1);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.NotDefined, result.ReasonCode);
+        Assert.NotEqual(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
+    }
+
+    [Fact]
+    public void DraftSave_Rejected_ForUndefinedDataset()
+    {
+        var ex = Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForSave(StandardRule("TOTALLY_UNKNOWN_DATASET"), Catalog()));
+        Assert.Contains("TOTALLY_UNKNOWN_DATASET", ex.Message);
+    }
+
+    // ============================================================
+    // C. Capability mismatch
+    // ============================================================
+
+    [Fact]
+    public void Enable_Rejected_WhenDatasetDoesNotSupportRuleType()
+    {
+        var registry = new S8MonitoringDataProviderRegistry(Array.Empty<IS8MonitoringDataProvider>());
+        var gate = new S8DatasetEnableGate(Catalog(), registry);
+
+        // OUT_OF_RANGE-only 数据集挂 TIMEOUT 规则。
+        var result = gate.Check(StandardRule(OutOfRangeOnlyDataset, "TIMEOUT"), 1, 1);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.CapabilityMismatch, result.ReasonCode);
+    }
+
+    // ============================================================
+    // D. 必需 canonical 字段缺失(单一布尔位不可信)
+    // ============================================================
+
+    [Fact]
+    public void Enable_Rejected_WhenMechanismClaimedButRequiredFieldMissing()
+    {
+        // SupportsTimeout=true 但 HasDueAt=false:配置自相矛盾,必须识别。
+        var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
+        var result = gate.Check(StandardRule(ContradictoryDataset), 1, 1);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.MissingRequiredField, result.ReasonCode);
+        Assert.Contains(S8CanonicalColumns.DueAt, result.Message);
+    }
+
+    [Fact]
+    public void RuleTypeRequirements_MatchRealEvaluatorNeeds()
+    {
+        Assert.Equal(
+            new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.DueAt, S8CanonicalColumns.Status },
+            S8RuleTypeFieldRequirements.For("TIMEOUT"));
+        Assert.Equal(
+            new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.MeasuredValue },
+            S8RuleTypeFieldRequirements.For("OUT_OF_RANGE"));
+        Assert.Equal(
+            new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.TargetQty, S8CanonicalColumns.ActualQty },
+            S8RuleTypeFieldRequirements.For("SHORTAGE"));
+    }
+
+    // ============================================================
+    // Disabled dataset
+    // ============================================================
+
+    [Fact]
+    public void Enable_Rejected_WhenDatasetLifecycleDisabled()
+    {
+        var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
+        var result = gate.Check(StandardRule(DefinedButDisabled), 1, 1);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.NotReady, result.ReasonCode);
+    }
+
+    // ============================================================
+    // G. Tenant / Factory scope
+    // ============================================================
+
+    [Fact]
+    public void Enable_Rejected_OnInvalidTenantScope()
+    {
+        var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
+        var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 0, factoryId: 1);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.InvalidTenantScope, result.ReasonCode);
+    }
+
+    [Fact]
+    public void Enable_Rejected_OnInvalidFactoryScope()
+    {
+        var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
+        var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 1, factoryId: 0);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.InvalidFactoryScope, result.ReasonCode);
+    }
+
+    [Fact]
+    public void Enable_Passes_WhenEverythingSatisfied()
+    {
+        var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
+        var result = gate.Check(StandardRule(DefinedWithProvider), 797403760988229, 1);
+
+        Assert.True(result.Ok);
+        Assert.Null(result.ReasonCode);
+    }
+
+    // ============================================================
+    // F. Legacy 规则不经过 Dataset Gate
+    // ============================================================
+
+    [Fact]
+    public void LegacyRule_NotSubjectToDatasetGate()
+    {
+        var legacy = new AdoS8WatchRule
+        {
+            RuleCode = "LEGACY",
+            RuleType = "TIMEOUT",
+            DataAccessMode = null,
+            DatasetCode = null,
+            Expression = "SELECT 1"
+        };
+
+        var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
+        Assert.True(gate.Check(legacy, 1, 1).Ok);
+        S8WatchRuleDataAccessValidator.ValidateForSave(legacy, Catalog());
+    }
+
+    // ============================================================
+    // 诊断能力
+    // ============================================================
+
+    [Fact]
+    public void Diagnostics_DistinguishesReadyFromProviderMissing()
+    {
+        var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider());
+
+        Assert.Equal(S8DatasetStatus.Ready, diagnostics.Describe(DefinedWithProvider)!.EffectiveStatus);
+        Assert.Equal(S8DatasetStatus.ProviderMissing, diagnostics.Describe(DefinedWithoutProvider)!.EffectiveStatus);
+        Assert.Equal(S8DatasetStatus.Disabled, diagnostics.Describe(DefinedButDisabled)!.EffectiveStatus);
+        Assert.Null(diagnostics.Describe("TOTALLY_UNKNOWN_DATASET"));
+    }
+
+    [Fact]
+    public void Diagnostics_ReportsUsableRuleTypes_ExcludingContradictoryOnes()
+    {
+        var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider());
+
+        Assert.Contains("TIMEOUT", diagnostics.Describe(DefinedWithProvider)!.UsableRuleTypes);
+        // 声明支持 TIMEOUT 但缺 due_at:不得出现在可用列表里。
+        Assert.DoesNotContain("TIMEOUT", diagnostics.Describe(ContradictoryDataset)!.UsableRuleTypes);
+        Assert.Contains("OUT_OF_RANGE", diagnostics.Describe(OutOfRangeOnlyDataset)!.UsableRuleTypes);
+    }
+
+    [Fact]
+    public void Diagnostics_ListsAllDefinedDatasets_RegardlessOfProvider()
+    {
+        var diagnostics = new S8DatasetDiagnosticsService(Catalog(), EmptyRegistry());
+        var all = diagnostics.List();
+        Assert.Equal(5, all.Count);
+        Assert.All(all, d => Assert.False(d.ProviderRegistered));
+    }
+}

+ 205 - 0
server/Admin.NET.Test/S8/S8MonitoringDataGatewayTests.cs

@@ -0,0 +1,205 @@
+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 Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// S8-DATASET-FOUNDATION-HARDENING-2:Gateway 分派与"绝不回落"边界。
+///
+/// 最关键的一条:STANDARD_DATASET 取数失败**不得**偷偷执行 rule.expression。
+/// 否则迁移失败会被静默隐藏——规则看起来在正常跑,实际跑的还是旧 SQL。
+/// </summary>
+public class S8MonitoringDataGatewayTests
+{
+    private const string ReadyDataset = "FAKE_GATEWAY_DATASET";
+
+    private static S8DatasetCapabilities TimeoutCapable() => new()
+    {
+        SupportsTimeout = true,
+        HasSourceObjectId = true,
+        HasRelatedObjectCode = true,
+        HasDueAt = true,
+        HasStatus = true
+    };
+
+    private sealed class FakeSource : IS8DatasetDefinitionSource
+    {
+        public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
+        {
+            new S8DatasetDefinition
+            {
+                DatasetCode = ReadyDataset,
+                DisplayName = "网关测试数据集",
+                Kind = S8DatasetKind.Object,
+                Capabilities = TimeoutCapable()
+            }
+        };
+    }
+
+    /// <summary>记录是否被调用过的 Legacy 探针。任何一次调用都意味着发生了回落。</summary>
+    private sealed class SpyLegacyProvider : IS8LegacySqlDataProvider
+    {
+        public int CallCount { get; private set; }
+
+        public Task<S8MonitoringDataResult> LoadAsync(
+            long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
+            int timeoutSeconds, CancellationToken cancellationToken = default)
+        {
+            CallCount++;
+            return Task.FromResult(new S8MonitoringDataResult
+            {
+                RowSet = S8MonitoringRowSet.Empty,
+                DataSourceId = 42,
+                DataAccessMode = S8DataAccessMode.LegacySql
+            });
+        }
+    }
+
+    private sealed class ThrowingProvider : IS8MonitoringDataProvider
+    {
+        public string DatasetCode => ReadyDataset;
+        public S8DatasetCapabilities Capabilities => TimeoutCapable();
+        public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
+            => throw new InvalidOperationException("provider blew up");
+    }
+
+    private sealed class OkProvider : IS8MonitoringDataProvider
+    {
+        public string DatasetCode => ReadyDataset;
+        public S8DatasetCapabilities Capabilities => TimeoutCapable();
+        public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
+        {
+            request.EnsureValid();
+            return Task.FromResult(S8MonitoringRowSet.FromRows(new[]
+            {
+                S8MonitoringRow.FromValues(new Dictionary<string, object>
+                {
+                    [S8CanonicalColumns.SourceObjectId] = "OBJ-1",
+                    [S8CanonicalColumns.RelatedObjectCode] = "OBJ-1",
+                    [S8CanonicalColumns.DueAt] = new DateTime(2026, 1, 1),
+                    [S8CanonicalColumns.Status] = "RUNNING"
+                })
+            }));
+        }
+    }
+
+    private static S8MonitoringDataGateway Gateway(
+        IS8MonitoringDataProvider provider, SpyLegacyProvider legacy) =>
+        new(legacy,
+            new S8MonitoringDataProviderRegistry(new[] { provider }),
+            new S8DatasetCatalog(new[] { new FakeSource() }),
+            NullLogger<S8MonitoringDataGateway>.Instance);
+
+    private static AdoS8WatchRule StandardRule() => new()
+    {
+        Id = 1,
+        RuleCode = "UT_GATEWAY_RULE",
+        RuleType = "TIMEOUT",
+        DataAccessMode = S8DataAccessMode.StandardDataset,
+        DatasetCode = ReadyDataset,
+        // 刻意留一条可执行 SQL:若实现存在回落,这条会被执行,探针即被调用。
+        Expression = "SELECT 1"
+    };
+
+    // ============================================================
+    // E. STANDARD_DATASET 绝不回落 Legacy
+    // ============================================================
+
+    [Fact]
+    public async Task StandardDataset_ProviderThrows_DoesNotFallBackToLegacySql()
+    {
+        var legacy = new SpyLegacyProvider();
+        var gateway = Gateway(new ThrowingProvider(), legacy);
+
+        var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
+            gateway.LoadAsync(1, 1, StandardRule(), "TIMEOUT", 60, 1000));
+
+        Assert.Equal("dataset_query_failed", ex.Reason);
+        Assert.Equal(0, legacy.CallCount);   // ← 本测试的核心断言
+    }
+
+    [Fact]
+    public async Task StandardDataset_Success_DoesNotTouchLegacyProvider()
+    {
+        var legacy = new SpyLegacyProvider();
+        var gateway = Gateway(new OkProvider(), legacy);
+
+        var result = await gateway.LoadAsync(11, 22, StandardRule(), "TIMEOUT", 60, 1000);
+
+        Assert.Single(result.RowSet.Rows);
+        Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode);
+        Assert.Equal(0, result.DataSourceId);
+        Assert.Equal(0, legacy.CallCount);
+    }
+
+    [Fact]
+    public async Task StandardDataset_UndefinedDataset_FailsWithNotDefined_NoLegacyFallback()
+    {
+        var legacy = new SpyLegacyProvider();
+        var gateway = Gateway(new OkProvider(), legacy);
+        var rule = StandardRule();
+        rule.DatasetCode = "NOT_IN_CATALOG";
+
+        var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
+            gateway.LoadAsync(1, 1, rule, "TIMEOUT", 60, 1000));
+
+        Assert.Equal(S8DatasetReasonCode.NotDefined, ex.Reason);
+        Assert.Equal(0, legacy.CallCount);
+    }
+
+    [Fact]
+    public async Task StandardDataset_InvalidScope_Fails_NoLegacyFallback()
+    {
+        var legacy = new SpyLegacyProvider();
+        var gateway = Gateway(new OkProvider(), legacy);
+
+        await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
+            gateway.LoadAsync(0, 1, StandardRule(), "TIMEOUT", 60, 1000));
+
+        Assert.Equal(0, legacy.CallCount);
+    }
+
+    // ============================================================
+    // F. Legacy 路径仍然可达
+    // ============================================================
+
+    [Fact]
+    public async Task LegacyRule_RoutesToLegacyProvider()
+    {
+        var legacy = new SpyLegacyProvider();
+        var gateway = Gateway(new OkProvider(), legacy);
+        var rule = new AdoS8WatchRule
+        {
+            RuleCode = "LEGACY_RULE",
+            RuleType = "TIMEOUT",
+            DataAccessMode = null,       // 132 条历史规则形态
+            DatasetCode = null,
+            Expression = "SELECT 1"
+        };
+
+        var result = await gateway.LoadAsync(1, 1, rule, "TIMEOUT", 60, 1000);
+
+        Assert.Equal(1, legacy.CallCount);
+        Assert.Equal(S8DataAccessMode.LegacySql, result.DataAccessMode);
+        Assert.Equal(42, result.DataSourceId);
+    }
+
+    // ============================================================
+    // 行数上限对两条路径同样生效
+    // ============================================================
+
+    [Fact]
+    public async Task RowLimitGuard_AppliesToStandardDatasetPath()
+    {
+        var legacy = new SpyLegacyProvider();
+        var gateway = Gateway(new OkProvider(), legacy);
+
+        var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
+            gateway.LoadAsync(1, 1, StandardRule(), "TIMEOUT", 60, maxRows: 0));
+
+        Assert.Equal("result_too_many_rows", ex.Reason);
+    }
+}

+ 106 - 31
server/Admin.NET.Test/S8/S8WatchRuleDataAccessValidatorTests.cs

@@ -7,22 +7,58 @@ using Xunit;
 namespace Admin.NET.Test.S8;
 
 /// <summary>
-/// S8-DATASET-PROVIDER-FOUNDATION-1 规则取数配置校验。
+/// 规则取数配置校验。
 ///
-/// 关键要求:能力错配必须在创建 / 启用期被拒绝,而不是等到运行期解析不到列后静默不命中。
+/// S8-DATASET-FOUNDATION-HARDENING-2 起分两级:
+///   · ValidateForSave   —— 结构 + 目录归属,不要求 Provider 已上线(允许存草稿);
+///   · ValidateForEnable —— 完整运行条件(委托 Enable Gate)。
 /// </summary>
 public class S8WatchRuleDataAccessValidatorTests
 {
+    private const string ReadyDataset = "FAKE_VALIDATOR_DATASET";
+    private const string DefinedNoProvider = "FAKE_VALIDATOR_DATASET_NO_PROVIDER";
+
+    private static S8DatasetCapabilities TimeoutCapable() => new()
+    {
+        SupportsTimeout = true,
+        HasSourceObjectId = true,
+        HasRelatedObjectCode = true,
+        HasDueAt = true,
+        HasStatus = true
+    };
+
+    private sealed class FakeSource : IS8DatasetDefinitionSource
+    {
+        public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
+        {
+            new S8DatasetDefinition
+            {
+                DatasetCode = ReadyDataset,
+                DisplayName = "校验测试数据集",
+                Kind = S8DatasetKind.Object,
+                Capabilities = TimeoutCapable()
+            },
+            new S8DatasetDefinition
+            {
+                DatasetCode = DefinedNoProvider,
+                DisplayName = "已定义未实现数据集",
+                Kind = S8DatasetKind.Object,
+                Capabilities = TimeoutCapable()
+            }
+        };
+    }
+
     private sealed class TimeoutOnlyProvider : IS8MonitoringDataProvider
     {
-        public string DatasetCode => "UT_TIMEOUT_DATASET";
-        public S8DatasetCapabilities Capabilities => new() { SupportsTimeout = true };
+        public string DatasetCode => ReadyDataset;
+        public S8DatasetCapabilities Capabilities => TimeoutCapable();
         public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
             => Task.FromResult(S8MonitoringRowSet.Empty);
     }
 
-    private static S8MonitoringDataProviderRegistry Registry() =>
-        new(new[] { new TimeoutOnlyProvider() });
+    private static S8DatasetCatalog Catalog() => new(new[] { new FakeSource() });
+    private static S8DatasetEnableGate Gate() =>
+        new(Catalog(), new S8MonitoringDataProviderRegistry(new[] { new TimeoutOnlyProvider() }));
 
     private static AdoS8WatchRule Rule(string ruleType, string mode = null, string datasetCode = null, string expression = null) => new()
     {
@@ -33,19 +69,21 @@ public class S8WatchRuleDataAccessValidatorTests
         Expression = expression
     };
 
+    // ---------- Legacy ----------
+
     [Fact]
     public void LegacyRule_WithNullMode_AndExpression_IsAccepted()
     {
         // 132 条历史规则的形态:mode=NULL、dataset_code=NULL、expression 有值。
-        var rule = Rule("TIMEOUT", mode: null, datasetCode: null, expression: "SELECT 1");
-        S8WatchRuleDataAccessValidator.Validate(rule, Registry());
+        S8WatchRuleDataAccessValidator.ValidateForSave(
+            Rule("TIMEOUT", mode: null, datasetCode: null, expression: "SELECT 1"), Catalog());
     }
 
     [Fact]
     public void LegacyRule_WithoutExpression_IsRejected()
     {
-        var rule = Rule("TIMEOUT", mode: null, datasetCode: null, expression: null);
-        var ex = Assert.Throws<S8BizException>(() => S8WatchRuleDataAccessValidator.Validate(rule, Registry()));
+        var ex = Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForSave(Rule("TIMEOUT", expression: null), Catalog()));
         Assert.Contains("expression", ex.Message);
     }
 
@@ -53,48 +91,85 @@ public class S8WatchRuleDataAccessValidatorTests
     public void LegacyRule_MustNotCarryDatasetCode()
     {
         // 两种模式的字段混填会让实际生效路径不可预测,必须显式拒绝。
-        var rule = Rule("TIMEOUT", mode: S8DataAccessMode.LegacySql, datasetCode: "UT_TIMEOUT_DATASET", expression: "SELECT 1");
-        Assert.Throws<S8BizException>(() => S8WatchRuleDataAccessValidator.Validate(rule, Registry()));
+        var rule = Rule("TIMEOUT", mode: S8DataAccessMode.LegacySql, datasetCode: ReadyDataset, expression: "SELECT 1");
+        Assert.Throws<S8BizException>(() => S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog()));
     }
 
     [Fact]
-    public void StandardDatasetRule_WithoutDatasetCode_IsRejected()
+    public void LegacyRule_PassesEnableGate_WithoutDatasetChecks()
     {
-        var rule = Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: null);
-        var ex = Assert.Throws<S8BizException>(() => S8WatchRuleDataAccessValidator.Validate(rule, Registry()));
-        Assert.Contains("dataset_code", ex.Message);
+        var rule = Rule("TIMEOUT", mode: null, expression: "SELECT 1");
+        S8WatchRuleDataAccessValidator.ValidateForEnable(rule, Gate(), 1, 1);
     }
 
+    // ---------- Standard dataset · Save ----------
+
     [Fact]
-    public void StandardDatasetRule_WithUnregisteredDataset_IsRejected()
+    public void StandardDatasetRule_WithoutDatasetCode_IsRejectedOnSave()
     {
-        var rule = Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: "NO_SUCH_DATASET");
-        var ex = Assert.Throws<S8BizException>(() => S8WatchRuleDataAccessValidator.Validate(rule, Registry()));
-        Assert.Contains("NO_SUCH_DATASET", ex.Message);
+        var ex = Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForSave(
+                Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset), Catalog()));
+        Assert.Contains(S8DatasetReasonCode.CodeMissing, ex.Message);
     }
 
     [Fact]
-    public void StandardDatasetRule_WithCapabilityMismatch_IsRejected()
+    public void StandardDatasetRule_WithUndefinedDataset_IsRejectedOnSave()
     {
-        // TIMEOUT-only 数据集挂 OUT_OF_RANGE 规则:必须建单期拒绝,不能留到运行期静默不命中。
-        var rule = Rule("OUT_OF_RANGE", mode: S8DataAccessMode.StandardDataset, datasetCode: "UT_TIMEOUT_DATASET");
-        var ex = Assert.Throws<S8BizException>(() => S8WatchRuleDataAccessValidator.Validate(rule, Registry()));
-        Assert.Contains("不支持", ex.Message);
+        var ex = Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForSave(
+                Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: "NO_SUCH_DATASET"), Catalog()));
+        Assert.Contains(S8DatasetReasonCode.NotDefined, ex.Message);
     }
 
     [Fact]
-    public void StandardDatasetRule_Matching_IsAccepted_WithoutAnySql()
+    public void StandardDatasetRule_DefinedButProviderMissing_CanBeSavedAsDraft()
+    {
+        // 关键行为:Provider 未上线时仍可保存配置。
+        S8WatchRuleDataAccessValidator.ValidateForSave(
+            Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: DefinedNoProvider), Catalog());
+    }
+
+    // ---------- Standard dataset · Enable ----------
+
+    [Fact]
+    public void StandardDatasetRule_DefinedButProviderMissing_CannotBeEnabled()
+    {
+        var ex = Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForEnable(
+                Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: DefinedNoProvider),
+                Gate(), 1, 1));
+        Assert.Contains(S8DatasetReasonCode.ProviderNotRegistered, ex.Message);
+    }
+
+    [Fact]
+    public void StandardDatasetRule_WithCapabilityMismatch_CannotBeEnabled()
+    {
+        // TIMEOUT-only 数据集挂 OUT_OF_RANGE 规则。
+        var ex = Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForEnable(
+                Rule("OUT_OF_RANGE", mode: S8DataAccessMode.StandardDataset, datasetCode: ReadyDataset),
+                Gate(), 1, 1));
+        Assert.Contains(S8DatasetReasonCode.CapabilityMismatch, ex.Message);
+    }
+
+    [Fact]
+    public void StandardDatasetRule_Matching_IsEnabled_WithoutAnySql()
     {
         // 标准数据集规则不需要用户填写任何 SQL。
-        var rule = Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: "UT_TIMEOUT_DATASET", expression: null);
-        S8WatchRuleDataAccessValidator.Validate(rule, Registry());
+        var rule = Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: ReadyDataset, expression: null);
+        S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog());
+        S8WatchRuleDataAccessValidator.ValidateForEnable(rule, Gate(), 1, 1);
     }
 
+    // ---------- 其他 ----------
+
     [Fact]
     public void UnknownDataAccessMode_IsRejected()
     {
-        var rule = Rule("TIMEOUT", mode: "SOMETHING_ELSE", expression: "SELECT 1");
-        Assert.Throws<S8BizException>(() => S8WatchRuleDataAccessValidator.Validate(rule, Registry()));
+        Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForSave(
+                Rule("TIMEOUT", mode: "SOMETHING_ELSE", expression: "SELECT 1"), Catalog()));
     }
 
     [Fact]
@@ -104,6 +179,6 @@ public class S8WatchRuleDataAccessValidatorTests
         Assert.True(S8WatchRuleDataAccessValidator.RequiresDataSource(Rule("TIMEOUT", mode: null)));
         Assert.True(S8WatchRuleDataAccessValidator.RequiresDataSource(Rule("TIMEOUT", mode: S8DataAccessMode.LegacySql)));
         Assert.False(S8WatchRuleDataAccessValidator.RequiresDataSource(
-            Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: "UT_TIMEOUT_DATASET")));
+            Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: ReadyDataset)));
     }
 }

+ 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.478</AssemblyVersion>
-    <FileVersion>1.0.478</FileVersion>
-    <Version>1.0.478</Version>
+    <AssemblyVersion>1.0.479</AssemblyVersion>
+    <FileVersion>1.0.479</FileVersion>
+    <Version>1.0.479</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 76 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetCapabilities.cs

@@ -1,24 +1,54 @@
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 
 /// <summary>
-/// 数据集能力声明:该 Dataset 能支撑哪几类规则判定。
+/// 数据集能力声明,分两层
 ///
-/// 存在的理由:TIMEOUT 规则需要 due_at 语义,若挂到一个根本不产出到期时间的 Dataset 上,
-/// 运行期只会表现为"解析不到列 → 永不命中",是一种静默失效。能力声明把这种错配
-/// 提前到规则创建 / 启用时暴露。
+/// <b>A. 机制能力</b>(SupportsXxx):该数据集打算支撑哪几类规则判定。
+/// <b>B. 字段能力</b>(HasXxx):该数据集实际能提供哪些 canonical 字段。
+///
+/// 分两层的理由:单一布尔位不可信。声明 SupportsTimeout=true 却拿不出 due_at 是一种
+/// 自相矛盾的配置,运行期只会表现为"永不命中"的静默失效。两层交叉校验才能在启用前发现。
+///
+/// 字段能力回答的是"这个数据集有没有这个字段",与"某一行该字段值为 NULL"是两件事:
+/// 前者是能力问题(规则不能启用),后者由 evaluator 按原规则跳过该行。
 /// </summary>
 public sealed class S8DatasetCapabilities
 {
-    /// <summary>可支撑 TIMEOUT(需 due_at + status 语义)。</summary>
+    // ---------- A. 机制能力 ----------
+
+    /// <summary>可支撑 TIMEOUT。</summary>
     public bool SupportsTimeout { get; init; }
 
-    /// <summary>可支撑 SHORTAGE(需 target_qty + actual_qty 语义)。</summary>
+    /// <summary>可支撑 SHORTAGE。</summary>
     public bool SupportsShortage { get; init; }
 
-    /// <summary>可支撑 OUT_OF_RANGE(需 measured_value 语义)。</summary>
+    /// <summary>可支撑 OUT_OF_RANGE。</summary>
     public bool SupportsOutOfRange { get; init; }
 
-    /// <summary>该数据集是否支持给定规则类型。未知规则类型一律返回 false。</summary>
+    // ---------- B. 字段能力 ----------
+
+    /// <summary>能提供 source_object_id(对象身份 + DedupKey 输入)。</summary>
+    public bool HasSourceObjectId { get; init; }
+
+    /// <summary>能提供 related_object_code。</summary>
+    public bool HasRelatedObjectCode { get; init; }
+
+    /// <summary>能提供 due_at。</summary>
+    public bool HasDueAt { get; init; }
+
+    /// <summary>能提供 status。</summary>
+    public bool HasStatus { get; init; }
+
+    /// <summary>能提供 measured_value。</summary>
+    public bool HasMeasuredValue { get; init; }
+
+    /// <summary>能提供 target_qty。</summary>
+    public bool HasTargetQty { get; init; }
+
+    /// <summary>能提供 actual_qty。</summary>
+    public bool HasActualQty { get; init; }
+
+    /// <summary>该数据集是否声明支持给定规则类型(仅机制层,未校验字段是否齐备)。</summary>
     public bool Supports(string? ruleType)
     {
         if (string.IsNullOrWhiteSpace(ruleType)) return false;
@@ -31,4 +61,42 @@ public sealed class S8DatasetCapabilities
             _ => false
         };
     }
+
+    /// <summary>该数据集是否能提供给定 canonical 字段。</summary>
+    public bool HasField(string? canonicalColumn)
+    {
+        if (string.IsNullOrWhiteSpace(canonicalColumn)) return false;
+
+        return canonicalColumn.Trim().ToLowerInvariant() switch
+        {
+            S8CanonicalColumns.SourceObjectId => HasSourceObjectId,
+            S8CanonicalColumns.RelatedObjectCode => HasRelatedObjectCode,
+            S8CanonicalColumns.DueAt => HasDueAt,
+            S8CanonicalColumns.Status => HasStatus,
+            S8CanonicalColumns.MeasuredValue => HasMeasuredValue,
+            S8CanonicalColumns.TargetQty => HasTargetQty,
+            S8CanonicalColumns.ActualQty => HasActualQty,
+            _ => false
+        };
+    }
+
+    /// <summary>
+    /// 返回该规则类型缺少的 canonical 字段。空集合表示字段能力齐备。
+    /// 未知规则类型返回 null(由调用方按 unknown_rule_type 处理,与"字段缺失"区分)。
+    /// </summary>
+    public IReadOnlyList<string>? MissingFieldsFor(string? ruleType)
+    {
+        var required = S8RuleTypeFieldRequirements.For(ruleType);
+        if (required == null) return null;
+
+        return required.Where(field => !HasField(field)).ToList();
+    }
+
+    /// <summary>机制能力与字段能力是否都满足该规则类型。</summary>
+    public bool CanServe(string? ruleType)
+    {
+        if (!Supports(ruleType)) return false;
+        var missing = MissingFieldsFor(ruleType);
+        return missing != null && missing.Count == 0;
+    }
 }

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

@@ -0,0 +1,70 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+/// <summary>数据集目录:回答"系统里有没有这个数据集、它是什么"。不回答"谁供数"。</summary>
+public interface IS8DatasetCatalog
+{
+    /// <summary>该编码是否已定义。</summary>
+    bool IsDefined(string? datasetCode);
+
+    /// <summary>取定义;未定义返回 null。</summary>
+    S8DatasetDefinition? Find(string? datasetCode);
+
+    /// <summary>全部已定义数据集。</summary>
+    IReadOnlyCollection<S8DatasetDefinition> All { get; }
+}
+
+/// <summary>
+/// 默认目录实现:聚合所有 <see cref="IS8DatasetDefinitionSource"/>。
+///
+/// 与 <see cref="S8MonitoringDataProviderRegistry"/> 的分工:
+///   Catalog   → Dataset 是什么(定义、种类、能力、是否停用)
+///   Registry  → 当前哪个 Provider 实现它(运行时供数)
+///
+/// Batch 1 曾把两者合一("注册即目录"),导致"数据集不存在"与"Provider 未实现"
+/// 无法区分。本批拆开后,规则可以在 Provider 上线前先配置好。
+/// </summary>
+public class S8DatasetCatalog : IS8DatasetCatalog, ITransient
+{
+    private readonly Dictionary<string, S8DatasetDefinition> _definitions;
+
+    public S8DatasetCatalog(IEnumerable<IS8DatasetDefinitionSource> sources)
+    {
+        _definitions = new Dictionary<string, S8DatasetDefinition>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (var source in sources)
+        {
+            foreach (var definition in source.GetDefinitions())
+            {
+                if (string.IsNullOrWhiteSpace(definition.DatasetCode)) continue;
+
+                var code = definition.DatasetCode.Trim();
+
+                // 重复定义视为配置错误:静默取其一会让实际生效的能力声明不可预测。
+                if (_definitions.ContainsKey(code))
+                {
+                    throw new InvalidOperationException(
+                        $"数据集 {code} 被重复定义(来源包含 {source.GetType().Name})");
+                }
+
+                if (!S8DatasetKind.IsKnown(definition.Kind))
+                    throw new InvalidOperationException($"数据集 {code} 的 Kind 非法:{definition.Kind}");
+
+                if (!S8DatasetStatus.IsDeclarable(definition.Status))
+                    throw new InvalidOperationException($"数据集 {code} 的 Status 非法:{definition.Status}");
+
+                _definitions[code] = definition;
+            }
+        }
+    }
+
+    public IReadOnlyCollection<S8DatasetDefinition> All => _definitions.Values;
+
+    public bool IsDefined(string? datasetCode) =>
+        !string.IsNullOrWhiteSpace(datasetCode) && _definitions.ContainsKey(datasetCode!.Trim());
+
+    public S8DatasetDefinition? Find(string? datasetCode)
+    {
+        if (string.IsNullOrWhiteSpace(datasetCode)) return null;
+        return _definitions.TryGetValue(datasetCode.Trim(), out var definition) ? definition : null;
+    }
+}

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

@@ -0,0 +1,45 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+/// <summary>
+/// 数据集定义:描述"系统认知中存在这样一个数据集",
+/// 与"当前谁负责给它供数"(Provider Registry 的职责)严格分离。
+///
+/// 这样才能表达一个必要的中间状态:数据集已定义、规则可以先配好存草稿,
+/// 但 Provider 尚未实现,因此不允许启用。
+/// </summary>
+public sealed class S8DatasetDefinition
+{
+    /// <summary>数据集编码,规则通过它引用数据集。</summary>
+    public string DatasetCode { get; init; } = string.Empty;
+
+    /// <summary>展示名(配置界面用)。</summary>
+    public string DisplayName { get; init; } = string.Empty;
+
+    /// <summary>OBJECT(对象级)/ METRIC(指标级)。</summary>
+    public string Kind { get; init; } = S8DatasetKind.Object;
+
+    /// <summary>能力声明:机制层 + canonical 字段层。</summary>
+    public S8DatasetCapabilities Capabilities { get; init; } = new();
+
+    /// <summary>声明状态:DEFINED(在用)/ DISABLED(停用)。有效状态由诊断结合 Provider 注册情况计算。</summary>
+    public string Status { get; init; } = S8DatasetStatus.Defined;
+
+    /// <summary>说明(可选)。</summary>
+    public string? Description { get; init; }
+
+    /// <summary>定义本身是否处于可用状态(不含 Provider 判断)。</summary>
+    public bool IsActive => string.Equals(Status, S8DatasetStatus.Defined, StringComparison.OrdinalIgnoreCase);
+}
+
+/// <summary>
+/// 数据集定义来源。真实业务数据集将来通过实现本接口注册进 Catalog,
+/// 无需修改 Catalog 自身代码。
+///
+/// 本批(S8-DATASET-FOUNDATION-HARDENING-2)**不提供任何生产实现**:
+/// 字段级审计显示 6 个候选标准数据集全部为 READY_WITH_PLATFORM_GAP,
+/// 在数据平台补齐数据集级新鲜度判定之前不登记真实业务数据集。
+/// </summary>
+public interface IS8DatasetDefinitionSource
+{
+    IEnumerable<S8DatasetDefinition> GetDefinitions();
+}

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

@@ -0,0 +1,103 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+/// <summary>
+/// 数据集可用性诊断项。回答"这个数据集当前能不能用、能用于哪类规则",
+/// 不预览任何业务数据。
+/// </summary>
+public sealed class S8DatasetDiagnosticDto
+{
+    public string DatasetCode { get; init; } = string.Empty;
+    public string DisplayName { get; init; } = string.Empty;
+    public string Kind { get; init; } = string.Empty;
+    public string? Description { get; init; }
+
+    /// <summary>声明状态:DEFINED / DISABLED。</summary>
+    public string DeclaredStatus { get; init; } = string.Empty;
+
+    /// <summary>有效状态:READY / PROVIDER_MISSING / DISABLED。</summary>
+    public string EffectiveStatus { get; init; } = string.Empty;
+
+    public bool ProviderRegistered { get; init; }
+    public string? ProviderType { get; init; }
+
+    /// <summary>目录声明支持的规则类型(仅机制层)。</summary>
+    public IReadOnlyList<string> DeclaredRuleTypes { get; init; } = Array.Empty<string>();
+
+    /// <summary>真正可用的规则类型:机制支持 + canonical 字段齐备 + Provider 已注册且能力一致。</summary>
+    public IReadOnlyList<string> UsableRuleTypes { get; init; } = Array.Empty<string>();
+
+    /// <summary>各规则类型缺失的 canonical 字段,供配置界面提示。</summary>
+    public IReadOnlyDictionary<string, IReadOnlyList<string>> MissingFieldsByRuleType { get; init; }
+        = new Dictionary<string, IReadOnlyList<string>>();
+}
+
+/// <summary>
+/// 数据集诊断服务。为后续规则配置界面提供"哪些数据集当前可用于哪类规则"的依据,
+/// 使配置方不必先建规则再靠报错试探。
+/// </summary>
+public class S8DatasetDiagnosticsService : ITransient
+{
+    private readonly IS8DatasetCatalog _catalog;
+    private readonly S8MonitoringDataProviderRegistry _registry;
+
+    public S8DatasetDiagnosticsService(IS8DatasetCatalog catalog, S8MonitoringDataProviderRegistry registry)
+    {
+        _catalog = catalog;
+        _registry = registry;
+    }
+
+    /// <summary>列出全部已定义数据集(含尚无 Provider 的)。</summary>
+    public IReadOnlyList<S8DatasetDiagnosticDto> List() =>
+        _catalog.All.Select(Describe).OrderBy(x => x.DatasetCode, StringComparer.OrdinalIgnoreCase).ToList();
+
+    /// <summary>描述单个数据集;未定义返回 null(与"已定义但无 Provider"区分)。</summary>
+    public S8DatasetDiagnosticDto? Describe(string? datasetCode)
+    {
+        var definition = _catalog.Find(datasetCode);
+        return definition == null ? null : Describe(definition);
+    }
+
+    private S8DatasetDiagnosticDto Describe(S8DatasetDefinition definition)
+    {
+        var registered = _registry.IsRegistered(definition.DatasetCode);
+        var provider = registered ? _registry.Resolve(definition.DatasetCode) : null;
+
+        var effectiveStatus = !definition.IsActive
+            ? S8DatasetStatus.Disabled
+            : registered
+                ? S8DatasetStatus.Ready
+                : S8DatasetStatus.ProviderMissing;
+
+        var declared = S8RuleTypeFieldRequirements.KnownRuleTypes
+            .Where(rt => definition.Capabilities.Supports(rt))
+            .ToList();
+
+        // 可用 = 目录能力齐备 && (若已注册)Provider 能力也齐备。
+        var usable = declared
+            .Where(rt => definition.Capabilities.CanServe(rt))
+            .Where(rt => provider == null || provider.Capabilities.CanServe(rt))
+            .ToList();
+
+        var missing = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
+        foreach (var ruleType in declared)
+        {
+            var gaps = definition.Capabilities.MissingFieldsFor(ruleType);
+            if (gaps is { Count: > 0 }) missing[ruleType] = gaps;
+        }
+
+        return new S8DatasetDiagnosticDto
+        {
+            DatasetCode = definition.DatasetCode,
+            DisplayName = definition.DisplayName,
+            Kind = definition.Kind,
+            Description = definition.Description,
+            DeclaredStatus = definition.Status,
+            EffectiveStatus = effectiveStatus,
+            ProviderRegistered = registered,
+            ProviderType = provider?.GetType().Name,
+            DeclaredRuleTypes = declared,
+            UsableRuleTypes = usable,
+            MissingFieldsByRuleType = missing
+        };
+    }
+}

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

@@ -0,0 +1,144 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+/// <summary>Enable Gate 的检查结果。失败时带明确 reason code,便于前端与运维定位。</summary>
+public sealed class S8DatasetEnableCheck
+{
+    private S8DatasetEnableCheck(bool ok, string? reasonCode, string? message)
+    {
+        Ok = ok;
+        ReasonCode = reasonCode;
+        Message = message;
+    }
+
+    public bool Ok { get; }
+    public string? ReasonCode { get; }
+    public string? Message { get; }
+
+    public static S8DatasetEnableCheck Pass() => new(true, null, null);
+    public static S8DatasetEnableCheck Fail(string reasonCode, string message) => new(false, reasonCode, message);
+}
+
+/// <summary>
+/// STANDARD_DATASET 规则的统一启用前校验。
+///
+/// 校验顺序:<b>先查目录层可判定的问题,再查实现层问题</b>。
+/// 理由——"这个数据集做不了 TIMEOUT"是定义问题,等 Provider 上线也不会变好;
+/// 若先报 provider_not_registered 会误导配置方以为等实现即可。
+///
+///   1. dataset_code 是否填写      → dataset_code_missing
+///   2. Catalog 中是否定义         → dataset_not_defined
+///   3. 定义是否停用               → dataset_not_ready
+///   4. 规则类型是否已知           → dataset_unknown_rule_type
+///   5. 目录机制能力是否支持       → dataset_capability_mismatch
+///   6. 目录 canonical 字段是否齐备 → dataset_missing_required_field
+///   7. Provider 是否注册          → dataset_provider_not_registered
+///   8. Provider 编码是否一致      → dataset_provider_code_mismatch
+///   9. Provider 能力是否同样满足   → dataset_capability_mismatch / dataset_missing_required_field
+///  10. 租户 / 工厂作用域          → invalid_tenant_scope / invalid_factory_scope
+///
+/// LEGACY_SQL 规则不经过本 Gate(其校验由既有数据源 / expression 检查承担)。
+/// </summary>
+public class S8DatasetEnableGate : ITransient
+{
+    private readonly IS8DatasetCatalog _catalog;
+    private readonly S8MonitoringDataProviderRegistry _registry;
+
+    public S8DatasetEnableGate(IS8DatasetCatalog catalog, S8MonitoringDataProviderRegistry registry)
+    {
+        _catalog = catalog;
+        _registry = registry;
+    }
+
+    /// <summary>检查规则是否具备启用条件。LEGACY_SQL 直接通过。</summary>
+    public S8DatasetEnableCheck Check(AdoS8WatchRule rule, long tenantId, long factoryId)
+    {
+        if (!S8DataAccessMode.IsAcceptable(rule.DataAccessMode))
+            return S8DatasetEnableCheck.Fail(
+                "data_access_mode_invalid",
+                $"未知的取数模式 {rule.DataAccessMode}");
+
+        if (S8DataAccessMode.Resolve(rule.DataAccessMode) == S8DataAccessMode.LegacySql)
+            return S8DatasetEnableCheck.Pass();
+
+        // ① dataset_code
+        if (string.IsNullOrWhiteSpace(rule.DatasetCode))
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.CodeMissing,
+                "STANDARD_DATASET 模式的规则必须提供 dataset_code");
+
+        // ② Catalog 定义
+        var definition = _catalog.Find(rule.DatasetCode);
+        if (definition == null)
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.NotDefined,
+                $"数据集 {rule.DatasetCode} 未在目录中定义");
+
+        // ③ 定义是否停用
+        if (!definition.IsActive)
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.NotReady,
+                $"数据集 {definition.DatasetCode} 已停用,不能用于规则执行");
+
+        // ④ 规则类型已知
+        if (!S8RuleTypeFieldRequirements.IsKnown(rule.RuleType))
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.UnknownRuleType,
+                $"规则类型 {rule.RuleType} 不在 canonical 词表内,无法校验数据集能力");
+
+        // ⑤ 目录机制能力(定义层问题,先于 Provider 报出)
+        if (!definition.Capabilities.Supports(rule.RuleType))
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.CapabilityMismatch,
+                $"数据集 {definition.DatasetCode} 不支持 {rule.RuleType} 类规则");
+
+        // ⑥ 目录 canonical 字段齐备(防"声明支持某机制但拿不出必需字段"的自相矛盾配置)
+        var missing = definition.Capabilities.MissingFieldsFor(rule.RuleType);
+        if (missing is { Count: > 0 })
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.MissingRequiredField,
+                $"数据集 {definition.DatasetCode} 缺少 {rule.RuleType} 必需字段:{string.Join(", ", missing)}");
+
+        // ⑦ Provider 注册
+        if (!_registry.IsRegistered(definition.DatasetCode))
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.ProviderNotRegistered,
+                $"数据集 {definition.DatasetCode} 已定义但尚无 Provider 实现,可保存配置但不能启用");
+
+        // ⑧ Provider 自报编码与目录一致
+        var provider = _registry.Resolve(definition.DatasetCode);
+        if (!string.Equals(provider.DatasetCode?.Trim(), definition.DatasetCode, StringComparison.OrdinalIgnoreCase))
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.ProviderCodeMismatch,
+                $"Provider 自报数据集编码 {provider.DatasetCode} 与目录定义 {definition.DatasetCode} 不一致");
+
+        // ⑨ Provider 实际能力必须与目录声明同样满足(不一致时按更严格的一方拒绝)
+        if (!provider.Capabilities.Supports(rule.RuleType))
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.CapabilityMismatch,
+                $"Provider 实现不支持 {rule.RuleType} 类规则(数据集 {definition.DatasetCode})");
+
+        var providerMissing = provider.Capabilities.MissingFieldsFor(rule.RuleType);
+        if (providerMissing is { Count: > 0 })
+            return S8DatasetEnableCheck.Fail(
+                S8DatasetReasonCode.MissingRequiredField,
+                $"Provider 实现缺少 {rule.RuleType} 必需字段:{string.Join(", ", providerMissing)}");
+
+        // ⑩ 作用域
+        if (tenantId <= 0)
+            return S8DatasetEnableCheck.Fail(S8DatasetReasonCode.InvalidTenantScope, "租户作用域非法");
+        if (factoryId <= 0)
+            return S8DatasetEnableCheck.Fail(S8DatasetReasonCode.InvalidFactoryScope, "工厂作用域非法");
+
+        return S8DatasetEnableCheck.Pass();
+    }
+
+    /// <summary>检查并在失败时抛 <see cref="S8BizException"/>(配置类错误,API 层直接回给调用方)。</summary>
+    public void EnsureCanEnable(AdoS8WatchRule rule, long tenantId, long factoryId)
+    {
+        var result = Check(rule, tenantId, factoryId);
+        if (!result.Ok)
+            throw new S8BizException($"[{result.ReasonCode}] {result.Message}");
+    }
+}

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

@@ -0,0 +1,19 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+/// <summary>
+/// 数据集种类。区分"对象级"与"指标级",因为两者的规则语义天然不同:
+/// 对象级数据集回答"这一个具体业务对象现在怎么样",指标级回答"这一批的聚合结果是多少"。
+/// 本批只定义类型体系,不预登记任何真实业务数据集。
+/// </summary>
+public static class S8DatasetKind
+{
+    /// <summary>对象级:一行 = 一个业务对象(工单 / 订单 / 库存对象 / 检验批次 …)。</summary>
+    public const string Object = "OBJECT";
+
+    /// <summary>指标级:一行 = 一个聚合结果(合格率 / 达成率 / 完工率 …)。</summary>
+    public const string Metric = "METRIC";
+
+    public static bool IsKnown(string? kind) =>
+        string.Equals(kind, Object, StringComparison.OrdinalIgnoreCase)
+        || string.Equals(kind, Metric, StringComparison.OrdinalIgnoreCase);
+}

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

@@ -0,0 +1,45 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+/// <summary>
+/// Dataset 启用 / 取数失败的 reason code。
+///
+/// 与既有 evaluator reason 码(rule_not_configured / params_parse_failed / data_source_unavailable /
+/// query_failed / result_too_many_rows)同为 snake_case,可被同一套失败处理链消费。
+///
+/// 每个码只表达一件事,禁止合并 —— 合并会让运维分不清"配错了"和"还没实现"。
+/// </summary>
+public static class S8DatasetReasonCode
+{
+    /// <summary>规则声明 STANDARD_DATASET 但没填 dataset_code。</summary>
+    public const string CodeMissing = "dataset_code_missing";
+
+    /// <summary>Catalog 中查无此数据集(很可能是编码写错)。</summary>
+    public const string NotDefined = "dataset_not_defined";
+
+    /// <summary>数据集已定义,但当前没有 Provider 实现(可存草稿,不可启用)。</summary>
+    public const string ProviderNotRegistered = "dataset_provider_not_registered";
+
+    /// <summary>数据集定义已停用。</summary>
+    public const string NotReady = "dataset_not_ready";
+
+    /// <summary>Provider 自报的 DatasetCode 与 Catalog 定义不一致。</summary>
+    public const string ProviderCodeMismatch = "dataset_provider_code_mismatch";
+
+    /// <summary>数据集不支持该规则类型(机制层能力不匹配)。</summary>
+    public const string CapabilityMismatch = "dataset_capability_mismatch";
+
+    /// <summary>数据集缺少该规则类型必需的 canonical 字段(字段层能力不足)。</summary>
+    public const string MissingRequiredField = "dataset_missing_required_field";
+
+    /// <summary>租户作用域非法。</summary>
+    public const string InvalidTenantScope = "invalid_tenant_scope";
+
+    /// <summary>工厂作用域非法。</summary>
+    public const string InvalidFactoryScope = "invalid_factory_scope";
+
+    /// <summary>规则类型不在 canonical 词表内,无法解析其字段要求。</summary>
+    public const string UnknownRuleType = "dataset_unknown_rule_type";
+
+    /// <summary>Provider 取数抛错。绝不因此回落 Legacy SQL。</summary>
+    public const string QueryFailed = "dataset_query_failed";
+}

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

@@ -0,0 +1,36 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+/// <summary>
+/// 数据集状态。
+///
+/// 分两层,不要混用:
+///   · <b>声明状态</b>(写在 <see cref="S8DatasetDefinition.Status"/> 里)只有 DEFINED / DISABLED,
+///     表达"这个数据集定义本身是否还在用";
+///   · <b>有效状态</b>(诊断时计算)额外有 NOT_DEFINED / PROVIDER_MISSING / READY,
+///     表达"结合当前 Provider 注册情况,它现在能不能用"。
+///
+/// 这样才能把 Batch 1 混为一谈的两件事分开:
+/// "数据集根本不存在" vs "数据集已定义但供数实现还没上线"。
+/// </summary>
+public static class S8DatasetStatus
+{
+    /// <summary>声明状态:已定义且在用。</summary>
+    public const string Defined = "DEFINED";
+
+    /// <summary>声明状态:定义仍在但已停用,不可用于规则执行。</summary>
+    public const string Disabled = "DISABLED";
+
+    /// <summary>有效状态:Catalog 中查无此数据集。</summary>
+    public const string NotDefined = "NOT_DEFINED";
+
+    /// <summary>有效状态:已定义、在用,但当前没有 Provider 实现。</summary>
+    public const string ProviderMissing = "PROVIDER_MISSING";
+
+    /// <summary>有效状态:已定义、在用、Provider 已注册,可用于规则执行。</summary>
+    public const string Ready = "READY";
+
+    /// <summary>声明状态是否合法。</summary>
+    public static bool IsDeclarable(string? status) =>
+        string.Equals(status, Defined, StringComparison.OrdinalIgnoreCase)
+        || string.Equals(status, Disabled, StringComparison.OrdinalIgnoreCase);
+}

+ 16 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8LegacySqlDataProvider.cs

@@ -3,6 +3,21 @@ using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 
+/// <summary>
+/// LEGACY_SQL 取数抽象。抽出接口是为了让"STANDARD_DATASET 绝不回落 Legacy"
+/// 这条边界可以被测试真实验证(注入探针后断言调用次数为 0),而不是只靠代码审阅。
+/// </summary>
+public interface IS8LegacySqlDataProvider
+{
+    Task<S8MonitoringDataResult> LoadAsync(
+        long tenantId,
+        long factoryId,
+        AdoS8WatchRule rule,
+        string ruleType,
+        int timeoutSeconds,
+        CancellationToken cancellationToken = default);
+}
+
 /// <summary>
 /// LEGACY_SQL 取数适配器:把既有"规则自带 expression + data_source_id"的取数结果
 /// 转换为统一的 canonical 行集合,使 evaluator 不再直接依赖 <see cref="System.Data.DataTable"/>。
@@ -10,7 +25,7 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 /// 本类**逐行保留迁移前的行为**(数据源查询条件、失败 reason 码、异常消息措辞),
 /// 132 条历史规则的运行结果因此不发生变化。
 /// </summary>
-public class S8LegacySqlDataProvider : ITransient
+public class S8LegacySqlDataProvider : IS8LegacySqlDataProvider, ITransient
 {
     private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
     private readonly S8DataSourceRowLoader _rowLoader;

+ 50 - 15
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataGateway.cs

@@ -4,25 +4,33 @@ using Microsoft.Extensions.Logging;
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 
 /// <summary>
-/// evaluator 的统一取数入口:按规则的 data_access_mode 决定走 LEGACY_SQL 还是 STANDARD_DATASET,
+/// evaluator 的**唯一**取数入口:按规则的 data_access_mode 决定走 LEGACY_SQL 还是 STANDARD_DATASET,
 /// 两条路径都产出同一种 canonical 行集合。
 ///
-/// 这是本批要建立的那道边界——evaluator 从此不再需要知道数据来自哪张物理表、
-/// 是 SQL 还是标准数据集。
+/// Scheduler / Evaluator / RuleService 都不得自行 resolve Provider —— 分散 lookup 会让
+/// "哪条规则实际读了什么"变得不可追溯。
+///
+/// <b>硬边界:STANDARD_DATASET 失败绝不回落 LEGACY_SQL。</b>
+/// 回落会让迁移失败静默隐藏:规则看起来在正常跑,实际跑的还是旧 SQL。
+/// 本类中 legacy provider 只在 mode 解析为 LEGACY_SQL 时被调用一次,
+/// 标准数据集分支的任何异常路径都不触碰它(有专门测试断言调用次数为 0)。
 /// </summary>
 public class S8MonitoringDataGateway : ITransient
 {
-    private readonly S8LegacySqlDataProvider _legacyProvider;
+    private readonly IS8LegacySqlDataProvider _legacyProvider;
     private readonly S8MonitoringDataProviderRegistry _registry;
+    private readonly IS8DatasetCatalog _catalog;
     private readonly ILogger<S8MonitoringDataGateway> _logger;
 
     public S8MonitoringDataGateway(
-        S8LegacySqlDataProvider legacyProvider,
+        IS8LegacySqlDataProvider legacyProvider,
         S8MonitoringDataProviderRegistry registry,
+        IS8DatasetCatalog catalog,
         ILogger<S8MonitoringDataGateway> logger)
     {
         _legacyProvider = legacyProvider;
         _registry = registry;
+        _catalog = catalog;
         _logger = logger;
     }
 
@@ -57,21 +65,47 @@ public class S8MonitoringDataGateway : ITransient
         string ruleType,
         CancellationToken cancellationToken)
     {
-        var provider = _registry.Resolve(rule.DatasetCode);
+        // 运行期与启用期用同一套判据,避免"能启用但跑不了"或反之。
+        // 任何一条不满足都直接抛出,绝不回落 legacy provider。
+        if (string.IsNullOrWhiteSpace(rule.DatasetCode))
+            throw new S8RuleEvaluatorException(
+                S8DatasetReasonCode.CodeMissing,
+                $"{ruleType} 规则 {rule.RuleCode} 声明 STANDARD_DATASET 但未提供 dataset_code");
+
+        var definition = _catalog.Find(rule.DatasetCode)
+            ?? throw new S8RuleEvaluatorException(
+                S8DatasetReasonCode.NotDefined,
+                $"{ruleType} 规则 {rule.RuleCode} 引用的数据集 {rule.DatasetCode} 未在目录中定义");
+
+        if (!definition.IsActive)
+            throw new S8RuleEvaluatorException(
+                S8DatasetReasonCode.NotReady,
+                $"数据集 {definition.DatasetCode} 已停用");
+
+        if (!_registry.IsRegistered(definition.DatasetCode))
+            throw new S8RuleEvaluatorException(
+                S8DatasetReasonCode.ProviderNotRegistered,
+                $"数据集 {definition.DatasetCode} 尚无 Provider 实现");
+
+        var provider = _registry.Resolve(definition.DatasetCode);
 
         // 能力错配在此拦截:否则运行期只会表现为"解析不到列 → 永不命中"的静默失效。
-        if (!provider.Capabilities.Supports(ruleType))
-        {
+        if (!definition.Capabilities.Supports(ruleType) || !provider.Capabilities.Supports(ruleType))
             throw new S8RuleEvaluatorException(
-                "dataset_capability_mismatch",
-                $"数据集 {provider.DatasetCode} 不支持 {ruleType} 类规则(rule={rule.RuleCode})");
-        }
+                S8DatasetReasonCode.CapabilityMismatch,
+                $"数据集 {definition.DatasetCode} 不支持 {ruleType} 类规则(rule={rule.RuleCode})");
+
+        var missing = definition.Capabilities.MissingFieldsFor(ruleType);
+        if (missing is { Count: > 0 })
+            throw new S8RuleEvaluatorException(
+                S8DatasetReasonCode.MissingRequiredField,
+                $"数据集 {definition.DatasetCode} 缺少 {ruleType} 必需字段:{string.Join(", ", missing)}");
 
         var request = new S8MonitoringDataRequest
         {
             TenantId = tenantId,
             FactoryId = factoryId,
-            DatasetCode = provider.DatasetCode,
+            DatasetCode = definition.DatasetCode,
             RuleId = rule.Id,
             RuleCode = rule.RuleCode,
             RuleType = ruleType
@@ -89,15 +123,16 @@ public class S8MonitoringDataGateway : ITransient
         }
         catch (Exception ex)
         {
+            // 注意:这里只包装并向上抛,**不执行 rule.Expression**。
             throw new S8RuleEvaluatorException(
-                "dataset_query_failed",
-                $"{ruleType} 规则 {rule.RuleCode} 数据集 {provider.DatasetCode} 取数失败:{ex.Message}",
+                S8DatasetReasonCode.QueryFailed,
+                $"{ruleType} 规则 {rule.RuleCode} 数据集 {definition.DatasetCode} 取数失败:{ex.Message}",
                 ex);
         }
 
         _logger.LogDebug(
             "s8_dataset_loaded dataset={Dataset} rule={Rule} tenant={Tenant} factory={Factory} rows={Rows}",
-            provider.DatasetCode, rule.RuleCode, tenantId, factoryId, rowSet.Rows.Count);
+            definition.DatasetCode, rule.RuleCode, tenantId, factoryId, rowSet.Rows.Count);
 
         return new S8MonitoringDataResult
         {

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

@@ -0,0 +1,62 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+/// <summary>
+/// RuleType → 必需 canonical 字段的唯一权威定义。
+///
+/// 存在的理由:这套要求此前隐含在三个 evaluator 的取值代码里,
+/// 任何 service 想判断"某数据集能否支撑某规则"都得复制一份 <c>if (ruleType == ...)</c>。
+/// 集中一处后,evaluator 与校验层引用同一份定义,不会漂移。
+///
+/// 字段清单严格取自 evaluator 真实读取行为:
+///   · TIMEOUT      读 due_at + status 做判定,读 source_object_id / related_object_code 做身份;
+///   · OUT_OF_RANGE 读 measured_value 做判定(上下限为行内可选字段,不列为必需);
+///   · SHORTAGE     读 target_qty + actual_qty 做判定。
+/// </summary>
+public static class S8RuleTypeFieldRequirements
+{
+    private static readonly string[] IdentityFields =
+    {
+        S8CanonicalColumns.SourceObjectId,
+        S8CanonicalColumns.RelatedObjectCode
+    };
+
+    private static readonly IReadOnlyList<string> TimeoutFields = IdentityFields
+        .Concat(new[] { S8CanonicalColumns.DueAt, S8CanonicalColumns.Status })
+        .ToList();
+
+    private static readonly IReadOnlyList<string> OutOfRangeFields = IdentityFields
+        .Concat(new[] { S8CanonicalColumns.MeasuredValue })
+        .ToList();
+
+    private static readonly IReadOnlyList<string> ShortageFields = IdentityFields
+        .Concat(new[] { S8CanonicalColumns.TargetQty, S8CanonicalColumns.ActualQty })
+        .ToList();
+
+    /// <summary>已知规则类型(与 evaluator dispatch 词表一致)。</summary>
+    public static IReadOnlyList<string> KnownRuleTypes { get; } = new[]
+    {
+        S8TimeoutRuleEvaluator.RuleTypeCode,
+        S8ShortageRuleEvaluator.RuleTypeCode,
+        S8OutOfRangeRuleEvaluator.RuleTypeCode
+    };
+
+    /// <summary>
+    /// 取该规则类型的必需 canonical 字段。未知规则类型返回 null
+    /// (与"已知类型但字段缺失"区分,两者的处理方式不同)。
+    /// </summary>
+    public static IReadOnlyList<string>? For(string? ruleType)
+    {
+        if (string.IsNullOrWhiteSpace(ruleType)) return null;
+
+        return ruleType.Trim().ToUpperInvariant() switch
+        {
+            "TIMEOUT" => TimeoutFields,
+            "OUT_OF_RANGE" => OutOfRangeFields,
+            "SHORTAGE" => ShortageFields,
+            _ => null
+        };
+    }
+
+    /// <summary>该规则类型是否为 canonical 词表内的已知类型。</summary>
+    public static bool IsKnown(string? ruleType) => For(ruleType) != null;
+}

+ 23 - 13
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8WatchRuleDataAccessValidator.cs

@@ -5,9 +5,14 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 /// <summary>
 /// 规则取数配置校验(纯逻辑,不访问数据库)。
 ///
-/// 目的是把"能力错配"从运行期挪到创建 / 启用期:
-/// 例如把 TIMEOUT 规则挂到不产出 due_at 的数据集上,运行期只会表现为"永不命中",
-/// 是一种没有任何报错的静默失效,必须在配置阶段拒绝。
+/// 刻意分成两级,因为二者的时机与严格度不同:
+///
+/// <b>ValidateForSave(保存 / 草稿)</b>——只看结构:模式合法、编码填了且在目录里、
+/// LEGACY_SQL 有 expression。**不查 Provider**,允许"数据集已定义、Provider 未上线"时
+/// 先把规则配置好存下来。
+///
+/// <b>ValidateForEnable(启用)</b>——委托 <see cref="S8DatasetEnableGate"/> 做完整运行条件校验,
+/// 含 Provider 注册、能力与字段齐备、作用域。
 /// </summary>
 public static class S8WatchRuleDataAccessValidator
 {
@@ -22,9 +27,10 @@ public static class S8WatchRuleDataAccessValidator
     }
 
     /// <summary>
-    /// 校验规则的取数配置。失败抛 <see cref="S8BizException"/>(配置类错误,由 API 层返回给调用方)。
+    /// 保存 / 草稿级校验:只校验结构与目录归属,不要求 Provider 已上线。
+    /// 失败抛 <see cref="S8BizException"/>。
     /// </summary>
-    public static void Validate(AdoS8WatchRule rule, S8MonitoringDataProviderRegistry registry)
+    public static void ValidateForSave(AdoS8WatchRule rule, IS8DatasetCatalog catalog)
     {
         if (!S8DataAccessMode.IsAcceptable(rule.DataAccessMode))
             throw new S8BizException(
@@ -44,15 +50,19 @@ public static class S8WatchRuleDataAccessValidator
             return;
         }
 
-        // STANDARD_DATASET
+        // STANDARD_DATASET:编码必须填写且必须是目录中已定义的数据集(拦截编码写错),
+        // 但**不检查 Provider 是否已注册** —— 那是启用期的事。
         if (string.IsNullOrWhiteSpace(rule.DatasetCode))
-            throw new S8BizException("STANDARD_DATASET 模式的规则必须提供 dataset_code");
-
-        if (!registry.IsRegistered(rule.DatasetCode))
-            throw new S8BizException($"数据集 {rule.DatasetCode} 没有已注册的 Provider,无法用于规则取数");
+            throw new S8BizException($"[{S8DatasetReasonCode.CodeMissing}] STANDARD_DATASET 模式的规则必须提供 dataset_code");
 
-        var provider = registry.Resolve(rule.DatasetCode);
-        if (!provider.Capabilities.Supports(rule.RuleType))
-            throw new S8BizException($"数据集 {rule.DatasetCode} 不支持 {rule.RuleType} 类规则");
+        if (!catalog.IsDefined(rule.DatasetCode))
+            throw new S8BizException($"[{S8DatasetReasonCode.NotDefined}] 数据集 {rule.DatasetCode} 未在目录中定义");
     }
+
+    /// <summary>
+    /// 启用级校验:完整运行条件。失败抛 <see cref="S8BizException"/>,消息带 reason code。
+    /// </summary>
+    public static void ValidateForEnable(
+        AdoS8WatchRule rule, S8DatasetEnableGate gate, long tenantId, long factoryId) =>
+        gate.EnsureCanEnable(rule, tenantId, factoryId);
 }

+ 19 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs

@@ -12,18 +12,21 @@ public class S8WatchRuleService : ITransient
     private readonly SqlSugarRepository<AdoS8WatchRule> _rep;
     private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
     private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
-    private readonly S8MonitoringDataProviderRegistry _datasetRegistry;
+    private readonly IS8DatasetCatalog _datasetCatalog;
+    private readonly S8DatasetEnableGate _datasetEnableGate;
 
     public S8WatchRuleService(
         SqlSugarRepository<AdoS8WatchRule> rep,
         SqlSugarRepository<AdoS8DataSource> dataSourceRep,
         SqlSugarRepository<AdoS8SceneConfig> sceneRep,
-        S8MonitoringDataProviderRegistry datasetRegistry)
+        IS8DatasetCatalog datasetCatalog,
+        S8DatasetEnableGate datasetEnableGate)
     {
         _rep = rep;
         _dataSourceRep = dataSourceRep;
         _sceneRep = sceneRep;
-        _datasetRegistry = datasetRegistry;
+        _datasetCatalog = datasetCatalog;
+        _datasetEnableGate = datasetEnableGate;
     }
 
     public async Task<List<AdoS8WatchRule>> ListAsync(long tenantId, long factoryId) =>
@@ -111,6 +114,13 @@ public class S8WatchRuleService : ITransient
         }
 
         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);
@@ -340,9 +350,13 @@ public class S8WatchRuleService : ITransient
             .AnyAsync(x => x.Id != (id ?? 0) && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.RuleCode == body.RuleCode);
         if (exists) throw new S8BizException("监视规则编码已存在");
 
-        // S8-DATASET-PROVIDER-FOUNDATION-1:取数模式校验
+        // S8-DATASET-FOUNDATION-HARDENING-2:保存级校验(结构 + 目录归属,不要求 Provider 已上线)
         // data_access_mode 为 NULL / 空白(132 条历史规则的现状)解析为 LEGACY_SQL,行为不变。
-        S8WatchRuleDataAccessValidator.Validate(body, _datasetRegistry);
+        S8WatchRuleDataAccessValidator.ValidateForSave(body, _datasetCatalog);
+
+        // 只有真正要启用时才要求完整运行条件;草稿 / disabled 规则允许 Provider 未上线。
+        if (body.Enabled)
+            S8WatchRuleDataAccessValidator.ValidateForEnable(body, _datasetEnableGate, scope.TenantId, scope.FactoryId);
 
         // S8-TENANT-FACTORY-P0-CLOSURE-1:关联数据源必须同属可信作用域,禁止引用他租户数据源。
         // STANDARD_DATASET 由已注册 Provider 取数,无物理数据源概念,跳过本段校验。

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

@@ -67,6 +67,13 @@ public class Startup : AppStartup
         services.AddTransient<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.IModuleRebuildHandler, Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.S5ModuleRebuildHandler>();
         services.AddTransient<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.IModuleRebuildHandler, Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.S6ModuleRebuildHandler>();
         services.AddTransient<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.IModuleRebuildHandler, Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.S7ModuleRebuildHandler>();
+
+        // S8-DATASET-FOUNDATION-HARDENING-2:S8 取数底座的接口映射显式注册
+        // (与上方 IModuleRebuildLock 同一先例:本项目不依赖 ITransient 自动按接口注册)。
+        // IS8DatasetDefinitionSource / IS8MonitoringDataProvider 本批**刻意不注册任何实现**——
+        // Catalog 与 Registry 均能正确处理空集合,这是"尚未接生产数据集"的预期状态。
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8DatasetCatalog, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8DatasetCatalog>();
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8LegacySqlDataProvider, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8LegacySqlDataProvider>();
     }
 
     /// <summary>