Эх сурвалжийг харах

feat(s8)!: 物理删除 Legacy 取数体系,STANDARD_DATASET 成为唯一取数路径

Hard Cutover 第二步。上一批只做到「Legacy 禁止运行」,本批是「Legacy 不存在」。

最终架构(唯一路径)
  Data Platform → Governed Dataset → S8 Provider → Canonical Contract
                → Watch Rule → Evaluator → Detection → Exception
S8 不维护物理数据源、不执行任意 SQL、不保留 Legacy Rule。

删除的类型(运行期引用归零)
- AdoS8DataSource / S8DataSourceService / AdoS8ConfigDataSourcesController
- S8LegacySqlDataProvider(+ IS8LegacySqlDataProvider)/ S8DataSourceRowLoader
- S8SqlSugarScopeFactory —— 删前已取证:唯一真实消费方就是 S8DataSourceRowLoader,
  另两处命中均为文档注释,不是共享基础设施
- S8DataAccessMode(取数方式只剩一种,枚举退化为常量即无意义)
- S8RuleCreationPolicy / S8RuleCreationOrigin(其存在意义是区分"谁写的 SQL",
  而 SQL 已不存在)
- AdoS8WatchRule 的 data_source_id / expression / data_access_mode 三列
- Web S8DataSourceConfigPage.vue、FUNC-S8-015、SysMenu 种子条目与按钮权限点

DATA_ACCESS_MODE = REMOVED,而非保留单值。保留一个恒定值的模式字段,
只会让读者以为还有第二条路可选。规则的取数配置只剩 dataset_code。

Gateway / Validator / Scheduler / Evaluator 同步收敛
- Gateway 去掉 mode 分支与 legacy provider 依赖,不再知道 SQL / DataSourceId /
  连接串的存在;"取数失败不回落"从运行期行为变成类型系统事实——没有可回落的目标。
- Validator 删 RequiresDataSource 与 LEGACY 分支,只校验 dataset_code。
- Scheduler 删上一批加的三层 Legacy 防御(legacy_rule_runtime_retired 等),
  过滤谓词改为 dataset_code 非空。保留谓词而非只靠 Enable Gate:
  Gate 只在"切换启用"那一刻生效,dataset_code 可能在此之后被清空。
- 三个 evaluator 删 expressionRequired 与 EvaluateRows 的 dataSourceId 形参;
  不再解析 commandTimeout——S8 已不执行 SQL,超时归 Provider 自己负责。

向导后端改为 Dataset-only
S8ConfigDraftService 删除整套 SQL 生成机构(BuildExpression / ValidateSqlIdentifier /
SafeIdentifierPattern / BuildSoftDeletePredicate),它按 monitor_object.source_table
拼 SELECT 去读别人的业务表,是 S8 最后一处"自己造 SQL"。改为要求 wizard_json 显式
携带 datasetCode 并校验存在于 Catalog。刻意不做"监控对象 → 数据集"自动推断:
没有真实映射时推断出来的就是假 Provider,只会把失败推迟到运行期。
原取「租户下第一个 enabled 数据源」的隐式选择也一并去掉。

1.0.487.sql(破坏性,按依赖顺序,幂等)
  1) 删 Legacy 规则的异常单及其子表(exception 34 / timeline 40 / notification_log 3;
     decision 与 evidence 各 0)。34 条中 32 条状态 NEW 从未被认领,2 条为 UAT 测试单,
     40 条 timeline ≈ 每单 1.2 条即仅创建事件,无真实处理链。
  2) 删 detection_log 327 行、rule_detection_state 45 行
  3) 删 Legacy watch_rule 132 行
  4) DROP 三个 Legacy 列
  5) DROP TABLE ado_s8_data_source(预检确认零 FK / 零触发器 / 零视图 / 除 PRIMARY 无索引)
  6) 清 SysMenu 与其 SysRoleMenu 授权行

⚠️ 脚本内两处易错点已就地标注:
  · 判定 Legacy 必须显式写 IS NULL —— `<> 'STANDARD_DATASET'` 对 NULL 求值为 UNKNOWN,
    会漏掉全部 132 行;补集写法同时覆盖脏值。
  · rule_code 跨租户重名,且 Rule 01 正在其中
    (RULE_S4_PURCHASE_DELIVERY_DATE_DELAY 同时存在于 id=46 / 144 / 1329909460023),
    而 rule_detection_state 只有 rule_code 没有 rule_id,故按 code 的删除一律附加
    (tenant_id, factory_id) 限定。

测试:Legacy 兼容用例整体退场,改为断言"类型不存在"
- 删 S8LegacySqlGovernanceTests / S8LegacyRuntimeRetirementTests(守的是
  "Legacy 被拒绝",前提已消失)
- S8MonitoringDataGatewayTests 的逐用例 legacy.CallCount==0 改为一条结构性断言
  Gateway_HasNoLegacyDependency(检查构造依赖)+ WatchRule_HasNoSqlOrDataSourceFields
- S8AuthorizationGuardTests 控制器数量下限 25 → 23;数据源能力码标 Deprecated 保留墓碑

保留:Rule 01(1329909460023)与诊断规则(1329909460024),均为
STANDARD_DATASET / PURCHASE_DELIVERY / enabled=0,未触碰、未启用、未执行。

chore: bump version Web 2.4.379 / server 1.0.487
YY968XX 16 цаг өмнө
parent
commit
04ade60e16
39 өөрчлөгдсөн 443 нэмэгдсэн , 2004 устгасан
  1. 1 1
      Web/package.json
  2. 2 2
      Web/src/constants/aidopFuncCodes.ts
  3. 0 47
      Web/src/views/aidop/s8/config/S8DataSourceConfigPage.vue
  4. 18 10
      server/Admin.NET.Test/S8/S8DatasetCatalogAndGateTests.cs
  5. 0 215
      server/Admin.NET.Test/S8/S8LegacyRuntimeRetirementTests.cs
  6. 0 249
      server/Admin.NET.Test/S8/S8LegacySqlGovernanceTests.cs
  7. 13 40
      server/Admin.NET.Test/S8/S8MonitoringDataContractTests.cs
  8. 85 86
      server/Admin.NET.Test/S8/S8MonitoringDataGatewayTests.cs
  9. 6 4
      server/Admin.NET.Test/S8/S8PurchaseDeliveryDatasetTests.cs
  10. 24 57
      server/Admin.NET.Test/S8/S8PurchaseDeliveryProviderMappingTests.cs
  11. 26 83
      server/Admin.NET.Test/S8/S8WatchRuleDataAccessValidatorTests.cs
  12. 6 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  13. 126 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.487.sql
  14. 6 2
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8AuthorizationGuardTests.cs
  15. 0 166
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DataSourceWriteRetiredTests.cs
  16. 14 74
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantIsolationContractTests.cs
  17. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRuleWriteGuardTests.cs
  18. 4 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8PermissionCatalog.cs
  19. 0 94
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDataSourcesController.cs
  20. 0 41
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DataSource.cs
  21. 9 16
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8WatchRule.cs
  22. 5 22
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs
  23. 0 46
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DataAccessMode.cs
  24. 2 10
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs
  25. 0 94
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8LegacySqlDataProvider.cs
  26. 9 23
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataGateway.cs
  27. 5 10
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataResult.cs
  28. 0 109
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8RuleCreationPolicy.cs
  29. 12 44
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8WatchRuleDataAccessValidator.cs
  30. 0 119
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8DataSourceRowLoader.cs
  31. 9 11
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs
  32. 9 11
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs
  33. 0 39
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8SqlSugarScopeFactory.cs
  34. 9 12
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs
  35. 26 104
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ConfigDraftService.cs
  36. 0 91
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DataSourceService.cs
  37. 5 32
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs
  38. 10 32
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs
  39. 1 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.378",
+	"version": "2.4.379",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 2 - 2
Web/src/constants/aidopFuncCodes.ts

@@ -247,8 +247,8 @@ const FUNC_DEFS: FuncDef[] = [
 	{ code: 'FUNC-S8-011', name: '场景基础配置', paths: ['/aidop/s8/config/scenes'], names: ['aidopS8ScenarioConfig'] },
 	{ code: 'FUNC-S8-012', name: '通知分层配置', paths: ['/aidop/s8/config/notifications'], names: ['aidopS8NotificationLayerConfig'] },
 	{ code: 'FUNC-S8-013', name: '角色权限配置', paths: ['/aidop/s8/config/roles'], names: ['aidopS8RolePermissionConfig'] },
-	// FUNC-S8-014(报警规则配置)已随 S8-STANDARD-DATASET-HARD-CUTOVER-1 物理删除,编号不回收、不重排。
-	{ code: 'FUNC-S8-015', name: '数据源配置', paths: ['/aidop/s8/config/data-sources'], names: ['aidopS8DataSourceConfig'] },
+	// FUNC-S8-014(报警规则配置)与 FUNC-S8-015(数据源配置)已随
+	// S8-STANDARD-DATASET-HARD-CUTOVER-1 物理删除。编号不回收、不重排。
 	{ code: 'FUNC-S8-016', name: '监视规则配置', paths: ['/aidop/s8/config/watch-rules'], names: ['aidopS8WatchRuleConfig'] },
 	{ code: 'FUNC-S8-017', name: '异常类型配置', paths: ['/aidop/s8/config/exception-types'], names: ['aidopS8ExceptionTypeConfig'] },
 	{ code: 'FUNC-S8-018', name: '大屏卡片配置', paths: ['/aidop/s8/config/dashboard-cells'], names: ['aidopS8DashboardCellConfig'] },

+ 0 - 47
Web/src/views/aidop/s8/config/S8DataSourceConfigPage.vue

@@ -1,47 +0,0 @@
-<script setup lang="ts" name="aidopS8DataSourceConfig">
-import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
-
-const columns = [
-	{ key: 'dataSourceCode', label: '数据源编码', width: 180 },
-	{ key: 'type', label: '类型', width: 120 },
-	{ key: 'endpoint', label: '连接地址' },
-	{ key: 'enabled', label: '启用', width: 90 },
-	{ key: 'lastCheckStatus', label: '最近检测', width: 180 },
-];
-
-const fields = [
-	{ key: 'dataSourceCode', label: '数据源编码', type: 'input', required: true },
-	// S8-STEP6A-CFG-DATASRC-FIX-1(D-5):类型收敛为 SQL 单选,与后端 SupportedTypes 保持单一事实源。
-	// 原选项 HTTP / MQ **不被 S8DataSourceRowLoader 消费**(IsSupportedType 只认 SQL / API),
-	// 选中保存后 scheduler 阶段必然报 data_source_unavailable —— 属「页面允许创建不可消费配置」。
-	// API 型虽有 RowLoader 实现,但 auth_type 零消费、URL 可携带 token 而 MaskSecret 覆盖不到,
-	// 故本轮不作为正式可选项对外暴露。
-	{ key: 'type', label: '类型', type: 'select', required: true, options: [{ label: 'SQL', value: 'SQL' }] },
-	{ key: 'endpoint', label: '连接地址', type: 'input', required: true },
-	// S8-CONFIG-CLEANUP-DEMO-1:auth_type 当前 0 业务消费;仅渲染层隐藏。
-	{ key: 'authType', label: '认证方式', type: 'input', hidden: true },
-	{ key: 'enabled', label: '启用', type: 'switch' },
-] as const;
-
-const buildDefault = () => ({
-	dataSourceCode: '',
-	// S8-STEP6A-CFG-DATASRC-FIX-1(D-5):默认值同步收敛为 SQL;原 'HTTP' 会让「新增」表单一打开
-	// 就预置一个后端将拒绝、RowLoader 也无法消费的类型。
-	type: 'SQL',
-	endpoint: '',
-	authType: '',
-	enabled: true,
-});
-</script>
-
-<template>
-	<S8CrudConfigPage
-		title="数据源配置"
-		subtitle="S8 / 配置 / 数据源"
-		endpoint="/api/aidop/s8/config/data-sources"
-		:columns="columns"
-		:fields="fields"
-		:build-default="buildDefault"
-		:enable-test="true"
-	/>
-</template>

+ 18 - 10
server/Admin.NET.Test/S8/S8DatasetCatalogAndGateTests.cs

@@ -117,7 +117,6 @@ public class S8DatasetCatalogAndGateTests
     {
         RuleCode = "UT_RULE",
         RuleType = ruleType,
-        DataAccessMode = S8DataAccessMode.StandardDataset,
         DatasetCode = datasetCode
     };
 
@@ -271,24 +270,33 @@ public class S8DatasetCatalogAndGateTests
     }
 
     // ============================================================
-    // F. Legacy 规则不经过 Dataset Gate
+    // F. 无数据集的规则不得通过 Gate
     // ============================================================
+    //
+    // 原用例 LegacyRule_NotSubjectToDatasetGate 断言「mode=NULL + expression 的规则
+    // 直接 Pass Gate」——那时 Legacy 是一条合法的平行路径,Gate 只管 Dataset 侧。
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1 之后不再有平行路径:
+    // 一条没有 dataset_code 的规则不是"走另一条路",而是**根本无法取数**,必须被拦下。
 
     [Fact]
-    public void LegacyRule_NotSubjectToDatasetGate()
+    public void RuleWithoutDatasetCode_FailsGate()
     {
-        var legacy = new AdoS8WatchRule
+        var orphan = new AdoS8WatchRule
         {
-            RuleCode = "LEGACY",
+            RuleCode = "NO_DATASET",
             RuleType = "TIMEOUT",
-            DataAccessMode = null,
-            DatasetCode = null,
-            Expression = "SELECT 1"
+            DatasetCode = null
         };
 
         var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
-        Assert.True(gate.Check(legacy, 1, 1).Ok);
-        S8WatchRuleDataAccessValidator.ValidateForSave(legacy, Catalog());
+        var result = gate.Check(orphan, 1, 1);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.CodeMissing, result.ReasonCode);
+
+        // 保存级同样拦下:没有数据集的规则连草稿都不该存成"看起来可运行"的样子。
+        Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForSave(orphan, Catalog()));
     }
 
     // ============================================================

+ 0 - 215
server/Admin.NET.Test/S8/S8LegacyRuntimeRetirementTests.cs

@@ -1,215 +0,0 @@
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using Admin.NET.Plugin.AiDOP.Service.S8;
-using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
-using Xunit;
-
-namespace Admin.NET.Test.S8;
-
-/// <summary>
-/// S8-LEGACY-RUNTIME-RETIREMENT-1:STANDARD_DATASET 是唯一允许运行的取数模式。
-///
-/// <b>本文件锁的是「接线」,不是「策略」。</b>
-/// 策略本身(<c>IsRuntimeEligible</c> / <c>EnsureMaintenanceAllowed</c> 的判定语义)
-/// 在 <see cref="S8LegacySqlGovernanceTests"/> 中用普通单元测试全覆盖;
-/// 这里要回答的是另一个问题:**每一条进入运行态的通道,是否真的调用了那道门?**
-///
-/// 为什么必须用 IL 而不是行为测试:<c>ResumeAsync</c> / <c>RunNowAsync</c> 的门禁位于
-/// <c>LoadScopedAsync</c> 之后(必须先确认规则归属本租户,才能对它下结论),
-/// 因此无法像 <c>S8DataSourceWriteRetiredTests</c> 那样用 null 仓储在 DB 访问前截住;
-/// 而调度器的三层防御分布在 SQL 谓词与运行期守卫中,端到端验证需要真库真租约。
-/// 折中方案是**在字节码层断言调用点存在** —— 有人删掉门禁时会立刻红,
-/// 而不是等到某次生产 tick 才发现 Legacy 规则又跑起来了。
-///
-/// <b>探测器自身的可信度由反向用例保证</b>:<c>PauseAsync</c>(退出运行态,
-/// 按设计**不得**有门禁)必须被判定为「未调用」。若探测器坏成"永远返回 true",
-/// 那条反向用例会失败。没有这层自检,IL 断言只是安慰剂。
-/// </summary>
-public class S8LegacyRuntimeRetirementTests
-{
-    // ============================================================
-    // IL 调用点探测
-    // ============================================================
-
-    private const byte OpCall = 0x28;      // call
-    private const byte OpCallvirt = 0x6F;  // callvirt
-
-    /// <summary>
-    /// 取方法真正承载 IL 的那个 body。async 方法本身只是启动器,
-    /// 真实逻辑在编译器生成的状态机 <c>MoveNext</c> 里;直接读原方法会一无所获。
-    /// </summary>
-    private static MethodBase ResolveBodyCarrier(Type owner, string methodName)
-    {
-        var method = owner.GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
-                     ?? throw new InvalidOperationException($"找不到方法 {owner.Name}.{methodName}");
-
-        var asyncAttr = method.GetCustomAttribute<AsyncStateMachineAttribute>();
-        if (asyncAttr == null) return method;
-
-        return asyncAttr.StateMachineType.GetMethod("MoveNext", BindingFlags.NonPublic | BindingFlags.Instance)
-               ?? throw new InvalidOperationException($"{owner.Name}.{methodName} 的状态机没有 MoveNext");
-    }
-
-    /// <summary>
-    /// 判断 <paramref name="methodName"/> 的 IL 中是否存在对 <paramref name="target"/> 的调用。
-    ///
-    /// 实现是「扫描 call / callvirt 操作码 + 其后 4 字节 metadata token」的朴素匹配,
-    /// 不做完整 IL 游走。取舍说明:
-    ///   · **不可能漏报** —— 真有调用,这 5 个字节就一定按此形态出现;
-    ///   · 理论上可能误报(某个操作数的字节恰好等于该模式),概率可忽略,
-    ///     且误报方向是"以为有门禁",会被上面的反向自检用例暴露。
-    /// 调用方与被调方同属 Admin.NET.Plugin.AiDOP 程序集,因此 IL 中是 MethodDef token,
-    /// 与 <c>MethodInfo.MetadataToken</c> 直接相等,无需跨模块解析。
-    /// </summary>
-    private static bool CallsMethod(Type owner, string methodName, MethodInfo target)
-    {
-        var il = ResolveBodyCarrier(owner, methodName).GetMethodBody()?.GetILAsByteArray()
-                 ?? throw new InvalidOperationException($"{owner.Name}.{methodName} 取不到 IL");
-        Assert.NotEmpty(il);
-
-        var token = target.MetadataToken;
-        var b0 = (byte)(token & 0xFF);
-        var b1 = (byte)((token >> 8) & 0xFF);
-        var b2 = (byte)((token >> 16) & 0xFF);
-        var b3 = (byte)((token >> 24) & 0xFF);
-
-        for (var i = 0; i + 4 < il.Length; i++)
-        {
-            if (il[i] != OpCall && il[i] != OpCallvirt) continue;
-            if (il[i + 1] == b0 && il[i + 2] == b1 && il[i + 3] == b2 && il[i + 4] == b3)
-                return true;
-        }
-        return false;
-    }
-
-    private static MethodInfo Gate() =>
-        typeof(S8RuleCreationPolicy).GetMethod(nameof(S8RuleCreationPolicy.EnsureMaintenanceAllowed))!;
-
-    private static MethodInfo Eligibility() =>
-        typeof(S8RuleCreationPolicy).GetMethod(nameof(S8RuleCreationPolicy.IsRuntimeEligible))!;
-
-    // ============================================================
-    // 探测器自检 —— 必须先证明它会说 "没有"
-    // ============================================================
-
-    /// <summary>
-    /// PauseAsync 是**退出**运行态,按设计不得有退役门禁(否则数据集一出问题规则就关不掉)。
-    /// 它同时充当探测器的阴性对照:这条用例失败 = 探测器失效,其余 IL 断言全部不可信。
-    /// </summary>
-    [Fact]
-    public void Detector_ReportsAbsence_ForPauseAsync_WhichMustNotBeGated()
-    {
-        Assert.False(CallsMethod(typeof(S8WatchRuleService), "PauseAsync", Gate()));
-    }
-
-    // ============================================================
-    // T3 / T4:Resume 与 RunNow 是独立入队通道,各自必须带门
-    // ============================================================
-
-    /// <summary>
-    /// T3:ResumeAsync 会清 paused_until 并把 next_run_at 置为 NOW,
-    /// 即"让调度器下一轮拾取它" —— 这就是进入运行态。
-    /// 它不经过 ValidateForEnable,所以门禁必须显式挂在这里。
-    /// </summary>
-    [Fact]
-    public void ResumeAsync_IsGuardedByRetirementGate()
-    {
-        Assert.True(CallsMethod(typeof(S8WatchRuleService), "ResumeAsync", Gate()),
-            "ResumeAsync 必须调用 S8RuleCreationPolicy.EnsureMaintenanceAllowed,否则 Legacy 规则可经"
-            + "「恢复」重新入队");
-    }
-
-    /// <summary>T4:RunNowAsync 同理 —— 直接把 next_run_at 置为 NOW 排队执行。</summary>
-    [Fact]
-    public void RunNowAsync_IsGuardedByRetirementGate()
-    {
-        Assert.True(CallsMethod(typeof(S8WatchRuleService), "RunNowAsync", Gate()),
-            "RunNowAsync 必须调用 S8RuleCreationPolicy.EnsureMaintenanceAllowed");
-    }
-
-    // ============================================================
-    // T1 / T2 / T5:Enable 通道(Create-and-enable 与 UpdateParams 的共同必经点)
-    // ============================================================
-
-    /// <summary>
-    /// ValidateForEnable 是「Create 时带 enabled=true」与「UpdateParams 由停用切启用」
-    /// 两条路径的唯一交汇处,门禁挂在这里即可覆盖二者。
-    /// 判定语义(含 NULL 历史行、显式 LEGACY_SQL、STANDARD 放行)见
-    /// S8WatchRuleDataAccessValidatorTests 与 S8LegacySqlGovernanceTests。
-    /// </summary>
-    [Fact]
-    public void ValidateForEnable_IsGuardedByRetirementGate()
-    {
-        Assert.True(CallsMethod(typeof(S8WatchRuleDataAccessValidator), "ValidateForEnable", Gate()));
-    }
-
-    /// <summary>
-    /// UpdateParamsAsync 是 enabled 的唯一切换入口,必须经由 ValidateForEnable 拿到门禁。
-    /// 若将来有人在这里直连 Gate 而绕开 validator,本断言会红 —— 那也应当被复核。
-    /// </summary>
-    [Fact]
-    public void EnabledToggle_StillRoutesThroughValidator()
-    {
-        var validateForEnable = typeof(S8WatchRuleDataAccessValidator)
-            .GetMethod(nameof(S8WatchRuleDataAccessValidator.ValidateForEnable))!;
-        Assert.True(CallsMethod(typeof(S8WatchRuleService), "UpdateParamsAsync", validateForEnable));
-    }
-
-    // ============================================================
-    // T6:调度器纵深防御
-    // ============================================================
-
-    /// <summary>
-    /// T6 第 3 层:即使有人绕过 SQL 过滤与抢锁复查(手工改库 + 直接构造 lease),
-    /// RunSingleRuleAsync 也必须在触碰 evaluator 与 S8LegacySqlDataProvider **之前**停下。
-    ///
-    /// 前两层是 SQL 谓词(<c>data_access_mode = 'STANDARD_DATASET'</c>,
-    /// 靠三值逻辑天然排除 NULL),位于表达式树内、无法用 IL 断言,
-    /// 其正确性由 <see cref="PickReadyRules_FiltersByRuntimeEligibleMode"/> 的常量契约 +
-    /// 阶段 2 的真库冒烟共同保证。
-    /// </summary>
-    [Fact]
-    public void Scheduler_RunSingleRule_ChecksRuntimeEligibility()
-    {
-        Assert.True(CallsMethod(typeof(S8WatchSchedulerService), "RunSingleRuleAsync", Eligibility()),
-            "RunSingleRuleAsync 必须调用 S8RuleCreationPolicy.IsRuntimeEligible,"
-            + "否则手工改库置 enabled=1 的 Legacy 规则仍会被执行");
-    }
-
-    /// <summary>
-    /// T7 的常量侧契约:调度器用于过滤的模式值必须就是 STANDARD_DATASET 本身,
-    /// 不允许被改成别名或宽松匹配。
-    /// 这条同时守住"STANDARD_DATASET 规则仍然有资格被拾取" —— 若有人把过滤值改错,
-    /// 受害的不是 Legacy 而是 Rule 01。
-    /// </summary>
-    [Fact]
-    public void PickReadyRules_FiltersByRuntimeEligibleMode()
-    {
-        Assert.Equal("STANDARD_DATASET", S8DataAccessMode.StandardDataset);
-
-        // 与 SQL 谓词等价的语义复核:只有该值被判定为有运行资格。
-        Assert.True(S8RuleCreationPolicy.IsRuntimeEligible(
-            new Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8WatchRule
-            {
-                DataAccessMode = S8DataAccessMode.StandardDataset
-            }));
-    }
-
-    // ============================================================
-    // 历史兼容:解析语义不得被"顺手迁移"
-    // ============================================================
-
-    /// <summary>
-    /// 硬约束:NULL / 空白仍解析为 LEGACY_SQL。
-    /// 把历史行伪装成 STANDARD_DATASET 会让它们绕过 Enable Gate,
-    /// 去引用一个根本不存在的 Provider —— 那不是迁移,是把问题推到运行期。
-    /// </summary>
-    [Theory]
-    [InlineData(null)]
-    [InlineData("")]
-    [InlineData("   ")]
-    public void HistoricalNullMode_StillResolvesToLegacy_NotStandard(string? raw)
-    {
-        Assert.Equal(S8DataAccessMode.LegacySql, S8DataAccessMode.Resolve(raw));
-    }
-}

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

@@ -1,249 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-using Admin.NET.Plugin.AiDOP.Service.S8;
-using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
-using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
-using Microsoft.Extensions.Logging.Abstractions;
-using Xunit;
-
-namespace Admin.NET.Test.S8;
-
-/// <summary>
-/// S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:停止新增 SQL 技术债。
-///
-/// 治理口径不是"禁止一切 LEGACY_SQL"——132 条历史规则仍需运行,向导也仍在用服务端生成的 SQL。
-/// 真正要掐断的是**调用方自带 expression** 这条路径:它既是技术债来源,也是注入面。
-/// 因此按创建来源区分:
-///   · EXTERNAL_API(裸 REST,调用方自带 SQL)→ 拒绝
-///   · WIZARD_GENERATED(服务端字典白名单生成)→ 暂时放行,随向导迁移后收口
-/// </summary>
-public class S8LegacySqlGovernanceTests
-{
-    private static AdoS8WatchRule LegacyRule() => new()
-    {
-        RuleCode = "UT_NEW_LEGACY",
-        RuleType = "TIMEOUT",
-        DataAccessMode = null,                 // 等价 LEGACY_SQL
-        Expression = "SELECT * FROM some_physical_table WHERE 1=1"
-    };
-
-    private static AdoS8WatchRule StandardRule() => new()
-    {
-        RuleCode = "UT_NEW_STANDARD",
-        RuleType = "TIMEOUT",
-        DataAccessMode = S8DataAccessMode.StandardDataset,
-        DatasetCode = "ANY_DATASET"
-    };
-
-    // ============================================================
-    // C. 新建 Legacy SQL 规则
-    // ============================================================
-
-    [Fact]
-    public void ExternalApi_CreatingLegacySqlRule_IsRejected()
-    {
-        var ex = Assert.Throws<S8BizException>(() =>
-            S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.ExternalApi));
-        Assert.Contains(S8RuleCreationPolicy.LegacySqlCreationDisabledReason, ex.Message);
-    }
-
-    [Fact]
-    public void ExternalApi_CreatingLegacySqlRule_ExplicitMode_IsAlsoRejected()
-    {
-        // 显式写 LEGACY_SQL 与留空等价,不能靠显式声明绕过治理。
-        var rule = LegacyRule();
-        rule.DataAccessMode = S8DataAccessMode.LegacySql;
-        Assert.Throws<S8BizException>(() =>
-            S8RuleCreationPolicy.EnsureCreationAllowed(rule, S8RuleCreationOrigin.ExternalApi));
-    }
-
-    [Fact]
-    public void WizardGenerated_LegacySqlRule_IsNowAlsoRejected()
-    {
-        // 语义变更(S8-LEGACY-RUNTIME-RETIREMENT-1):本用例原名
-        // WizardGenerated_LegacySqlRule_StillAllowed —— 当时向导是唯一豁免通道,
-        // 因为它是在产流程且 SQL 由服务端按字典白名单生成。
-        //
-        // 该豁免现已取消:没有治理 Dataset 就没有可运行 Rule,不再用 Legacy SQL 兜底。
-        // 向导保留独立文案(提示"当前对象无治理数据集"),因为在向导里报
-        // "不接受你自带的 SQL" 会让用户完全对不上号 —— 他并没有写过 SQL。
-        var ex = Assert.Throws<S8BizException>(() =>
-            S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.WizardGenerated));
-        Assert.Contains(S8RuleCreationPolicy.LegacySqlRetiredReason, ex.Message);
-    }
-
-    [Fact]
-    public void StandardDatasetRule_AllowedFromAnyOrigin()
-    {
-        S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.ExternalApi);
-        S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.WizardGenerated);
-    }
-
-    [Fact]
-    public void UnknownOrigin_IsTreatedAsExternal_FailClosed()
-    {
-        // 未知来源按最严格处理:新增调用方不会因为忘记声明来源而意外获得放行。
-        Assert.Throws<S8BizException>(() =>
-            S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), "SOMETHING_NEW"));
-    }
-
-    // ============================================================
-    // D. 历史 Legacy 规则的维护不受治理影响
-    // ============================================================
-
-    [Fact]
-    public void ExistingLegacyRule_CanStillBeDisabled()
-    {
-        // 这条**必须**保持放行:若关停也被拦,数据集/数据源一出问题规则就再也关不掉,
-        // 只能去改库。退役门禁的方向是单向的 —— 只拦「进入运行态」。
-        var existing = LegacyRule();
-        S8RuleCreationPolicy.EnsureMaintenanceAllowed(existing, enabling: false);
-    }
-
-    [Fact]
-    public void ExistingLegacyRule_CannotBeReEnabled_AfterRuntimeRetirement()
-    {
-        // 语义变更(S8-LEGACY-RUNTIME-RETIREMENT-1):本用例原名
-        // ExistingLegacyRule_CanStillBeReEnabled —— 上一阶段的治理目标是"不新增旧债",
-        // 故存量规则重新启用是放行的。本阶段目标升级为"旧债不得运行",因此反转。
-        var ex = Assert.Throws<S8BizException>(() =>
-            S8RuleCreationPolicy.EnsureMaintenanceAllowed(LegacyRule(), enabling: true));
-        Assert.Contains(S8RuleCreationPolicy.LegacySqlRetiredReason, ex.Message);
-    }
-
-    [Fact]
-    public void ExplicitLegacyMode_CannotBeReEnabled_Either()
-    {
-        // NULL(历史现状)与显式 LEGACY_SQL 必须同样被拦,否则回填一次字面值就能绕过退役。
-        var rule = LegacyRule();
-        rule.DataAccessMode = S8DataAccessMode.LegacySql;
-        Assert.Throws<S8BizException>(() =>
-            S8RuleCreationPolicy.EnsureMaintenanceAllowed(rule, enabling: true));
-    }
-
-    [Fact]
-    public void StandardDatasetRule_CanBeEnabled()
-    {
-        // 正向对照:退役门禁不能误伤 STANDARD_DATASET(Rule 01 走的正是这条路)。
-        S8RuleCreationPolicy.EnsureMaintenanceAllowed(StandardRule(), enabling: true);
-    }
-
-    // ============================================================
-    // E. 运行资格判定(调度器纵深防御所依赖的同一个谓词)
-    // ============================================================
-
-    [Theory]
-    [InlineData(null)]              // 132 条历史规则的现状
-    [InlineData("")]                // 空串
-    [InlineData("   ")]             // 空白
-    [InlineData("LEGACY_SQL")]      // 显式声明
-    [InlineData("legacy_sql")]      // 大小写变体
-    [InlineData("SOMETHING_ELSE")]  // 脏数据:既非 Legacy 也非 Standard
-    public void IsRuntimeEligible_IsFalse_ForEverythingButStandardDataset(string? mode)
-    {
-        var rule = LegacyRule();
-        rule.DataAccessMode = mode;
-        Assert.False(S8RuleCreationPolicy.IsRuntimeEligible(rule));
-    }
-
-    [Theory]
-    [InlineData("STANDARD_DATASET")]
-    [InlineData("standard_dataset")]  // Resolve 对模式值大小写不敏感
-    public void IsRuntimeEligible_IsTrue_ForStandardDataset(string mode)
-    {
-        var rule = StandardRule();
-        rule.DataAccessMode = mode;
-        Assert.True(S8RuleCreationPolicy.IsRuntimeEligible(rule));
-    }
-
-    [Fact]
-    public void IsRuntimeEligible_DoesNotThrow_OnDirtyModeValue()
-    {
-        // 调度器每个 tick 都会调它。脏数据必须返回 false 而不是抛异常中断整个 tick,
-        // 否则一条坏行就能让全租户的监控停摆(S8DataAccessMode.Resolve 对未知值是抛错的)。
-        var rule = LegacyRule();
-        rule.DataAccessMode = "NOT_A_REAL_MODE";
-        Assert.False(S8RuleCreationPolicy.IsRuntimeEligible(rule));
-    }
-
-    // ============================================================
-    // B. STANDARD_DATASET 即使带恶意 SQL 也绝不执行
-    // ============================================================
-
-    private const string PoisonDataset = "FAKE_POISON_DATASET";
-
-    private static S8DatasetCapabilities TimeoutCapable() => new()
-    {
-        SupportsTimeout = true,
-        HasSourceObjectId = true,
-        HasRelatedObjectCode = true,
-        HasDueAt = true,
-        HasStatus = true
-    };
-
-    private sealed class PoisonSource : IS8DatasetDefinitionSource
-    {
-        public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
-        {
-            new S8DatasetDefinition
-            {
-                DatasetCode = PoisonDataset,
-                DisplayName = "恶意SQL防护测试数据集",
-                Kind = S8DatasetKind.Object,
-                Capabilities = TimeoutCapable()
-            }
-        };
-    }
-
-    private sealed class EmptyProvider : IS8MonitoringDataProvider
-    {
-        public string DatasetCode => PoisonDataset;
-        public S8DatasetCapabilities Capabilities => TimeoutCapable();
-        public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
-        {
-            request.EnsureValid();
-            return Task.FromResult(S8MonitoringRowSet.Empty);
-        }
-    }
-
-    private sealed class SpyLegacy : IS8LegacySqlDataProvider
-    {
-        public int CallCount { get; private set; }
-        public string LastExpression { get; private set; }
-
-        public Task<S8MonitoringDataResult> LoadAsync(
-            long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
-            int timeoutSeconds, CancellationToken cancellationToken = default)
-        {
-            CallCount++;
-            LastExpression = rule.Expression;
-            return Task.FromResult(new S8MonitoringDataResult { RowSet = S8MonitoringRowSet.Empty });
-        }
-    }
-
-    [Fact]
-    public async Task StandardDataset_MaliciousExpression_IsNeverExecuted()
-    {
-        var legacy = new SpyLegacy();
-        var gateway = new S8MonitoringDataGateway(
-            legacy,
-            new S8MonitoringDataProviderRegistry(new[] { new EmptyProvider() }),
-            new S8DatasetCatalog(new[] { new PoisonSource() }),
-            NullLogger<S8MonitoringDataGateway>.Instance);
-
-        var rule = new AdoS8WatchRule
-        {
-            RuleCode = "UT_POISON",
-            RuleType = "TIMEOUT",
-            DataAccessMode = S8DataAccessMode.StandardDataset,
-            DatasetCode = PoisonDataset,
-            // 即使历史遗留字段里躺着可执行 SQL,STANDARD_DATASET 路径也绝不能碰它。
-            Expression = "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN"
-        };
-
-        var result = await gateway.LoadAsync(1, 1, rule, "TIMEOUT", 60, 1000);
-
-        Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode);
-        Assert.Equal(0, legacy.CallCount);
-        Assert.Null(legacy.LastExpression);
-    }
-}

+ 13 - 40
server/Admin.NET.Test/S8/S8MonitoringDataContractTests.cs

@@ -20,38 +20,11 @@ namespace Admin.NET.Test.S8;
 /// </summary>
 public class S8MonitoringDataContractTests
 {
-    // ============================================================
-    // A / D. Legacy 兼容与向后兼容
-    // ============================================================
-
-    [Fact]
-    public void DataAccessMode_Null_ResolvesTo_LegacySql()
-    {
-        // 132 条历史规则 data_access_mode 列为 NULL,必须继续走原 SQL 路径。
-        Assert.Equal(S8DataAccessMode.LegacySql, S8DataAccessMode.Resolve(null));
-    }
-
-    [Fact]
-    public void DataAccessMode_EmptyOrWhitespace_ResolvesTo_LegacySql()
-    {
-        Assert.Equal(S8DataAccessMode.LegacySql, S8DataAccessMode.Resolve(""));
-        Assert.Equal(S8DataAccessMode.LegacySql, S8DataAccessMode.Resolve("   "));
-    }
-
-    [Fact]
-    public void DataAccessMode_StandardDataset_IsRecognized_CaseInsensitive()
-    {
-        Assert.Equal(S8DataAccessMode.StandardDataset, S8DataAccessMode.Resolve("STANDARD_DATASET"));
-        Assert.Equal(S8DataAccessMode.StandardDataset, S8DataAccessMode.Resolve("standard_dataset"));
-    }
-
-    [Fact]
-    public void DataAccessMode_UnknownValue_Throws_RatherThanSilentlyRunningLegacySql()
-    {
-        // 未知模式静默回落 LEGACY_SQL 会让配置错误伪装成"正常运行",因此必须显式失败。
-        var ex = Assert.Throws<S8RuleEvaluatorException>(() => S8DataAccessMode.Resolve("SOMETHING_ELSE"));
-        Assert.Equal("data_access_mode_invalid", ex.Reason);
-    }
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1:此处原有 4 条 DataAccessMode 解析用例
+    //   (NULL/空白 → LEGACY_SQL、STANDARD_DATASET 大小写不敏感、未知值抛错)。
+    // S8DataAccessMode 整个类型已删除 —— 取数方式只剩一种,枚举本身失去意义。
+    // 「不存在第二种取数方式」由 S8MonitoringDataGatewayTests.Gateway_HasNoLegacyDependency
+    // 与 WatchRule_HasNoSqlOrDataSourceFields 断言。
 
     // ============================================================
     // Canonical Row / RowSet 表示层:必须与原 DataTable 读取语义等价
@@ -169,7 +142,7 @@ public class S8MonitoringDataContractTests
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
             rowSet, parameters, NewRule("TIMEOUT"),
-            tenantId: 11, factoryId: 22, dataSourceId: 0,
+            tenantId: 11, factoryId: 22,
             detectedAt: new DateTime(2026, 6, 1, 0, 0, 0));
 
         var hit = Assert.Single(hits);
@@ -196,7 +169,7 @@ public class S8MonitoringDataContractTests
             """{"dueAtField":"due_at","statusField":"status","completedStates":["COMPLETED"],"exceptionTypeCode":"X"}""");
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("TIMEOUT"), 11, 22, 0, new DateTime(2026, 6, 1));
+            rowSet, parameters, NewRule("TIMEOUT"), 11, 22, new DateTime(2026, 6, 1));
 
         Assert.Empty(hits);
     }
@@ -218,7 +191,7 @@ public class S8MonitoringDataContractTests
             """{"dueAtField":"planner_end_date","statusField":"morder_state","exceptionTypeCode":"X"}""");
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("TIMEOUT"), 1, 1, 0, new DateTime(2026, 6, 1));
+            rowSet, parameters, NewRule("TIMEOUT"), 1, 1, new DateTime(2026, 6, 1));
 
         Assert.Single(hits);
         Assert.Equal("WO-1", hits[0].SourceObjectId);
@@ -240,7 +213,7 @@ public class S8MonitoringDataContractTests
             """{"measuredValueField":"measured_value","upperBound":100,"exceptionTypeCode":"QUALITY_OUT_OF_SPEC"}""");
 
         var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("OUT_OF_RANGE"), 11, 22, 0, DateTime.Now);
+            rowSet, parameters, NewRule("OUT_OF_RANGE"), 11, 22, DateTime.Now);
 
         var hit = Assert.Single(hits);
         Assert.Equal("ITEM-1", hit.SourceObjectId);
@@ -264,7 +237,7 @@ public class S8MonitoringDataContractTests
             """{"measuredValueField":"measured_value","upperBound":100,"lowerBound":10}""");
 
         var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("OUT_OF_RANGE"), 1, 1, 0, DateTime.Now);
+            rowSet, parameters, NewRule("OUT_OF_RANGE"), 1, 1, DateTime.Now);
 
         Assert.Empty(hits);
     }
@@ -285,7 +258,7 @@ public class S8MonitoringDataContractTests
             """{"targetQtyField":"target_qty","actualQtyField":"actual_qty","exceptionTypeCode":"MATERIAL_SHORTAGE"}""");
 
         var hits = S8ShortageRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("SHORTAGE"), 11, 22, 0, DateTime.Now);
+            rowSet, parameters, NewRule("SHORTAGE"), 11, 22, DateTime.Now);
 
         var hit = Assert.Single(hits);
         Assert.Equal("PO-1", hit.SourceObjectId);
@@ -311,7 +284,7 @@ public class S8MonitoringDataContractTests
             """{"dueAtField":"due_at","statusField":"status","exceptionTypeCode":"X"}""");
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, parameters, NewRule("TIMEOUT"), 1, 1, 0, new DateTime(2026, 6, 1));
+            rowSet, parameters, NewRule("TIMEOUT"), 1, 1, new DateTime(2026, 6, 1));
 
         Assert.Contains("extra_business_column", hits[0].SourcePayload);
         Assert.Contains("must-survive", hits[0].SourcePayload);
@@ -437,7 +410,7 @@ public class S8MonitoringDataContractTests
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
             rowSet,
             S8TimeoutParams.Parse("""{"dueAtField":"due_at","statusField":"status","exceptionTypeCode":"X"}"""),
-            NewRule("TIMEOUT"), 11, 22, 0, new DateTime(2026, 6, 1));
+            NewRule("TIMEOUT"), 11, 22, new DateTime(2026, 6, 1));
 
         Assert.Single(hits);
         Assert.Equal("T11:F22:RUT_DATASET_RULE:ORDER:T11-F22", hits[0].DedupKey);

+ 85 - 86
server/Admin.NET.Test/S8/S8MonitoringDataGatewayTests.cs

@@ -7,10 +7,25 @@ using Xunit;
 namespace Admin.NET.Test.S8;
 
 /// <summary>
-/// S8-DATASET-FOUNDATION-HARDENING-2:Gateway 分派与"绝不回落"边界
+/// Gateway 契约:dataset_code → Catalog → Provider Registry → Provider.LoadAsync
 ///
-/// 最关键的一条:STANDARD_DATASET 取数失败**不得**偷偷执行 rule.expression。
-/// 否则迁移失败会被静默隐藏——规则看起来在正常跑,实际跑的还是旧 SQL。
+/// <b>本文件的断言口径在 S8-STANDARD-DATASET-HARD-CUTOVER-1 发生过一次根本变化,
+/// 值得写清楚,否则会被误读成覆盖倒退。</b>
+///
+/// 此前每条用例都配一个 <c>SpyLegacyProvider</c>,核心断言是 <c>legacy.CallCount == 0</c> ——
+/// 因为当时 Gateway 里真有一条 LEGACY_SQL 分支,"取数失败会不会偷偷执行 rule.Expression"
+/// 是一个必须每次都验的真实风险。
+///
+/// 现在整条 Legacy 取数链(IS8LegacySqlDataProvider / S8LegacySqlDataProvider /
+/// S8DataSourceRowLoader)已物理删除,<c>rule.Expression</c> 字段本身也不存在了。
+/// 「不回落」不再是一条需要反复验证的运行期行为,而是**类型系统层面的事实**:
+/// 没有可回落的目标。因此:
+///   · 逐用例的 CallCount 断言全部移除(探针类型已不存在,写不出来);
+///   · 改为一条结构性断言 <see cref="Gateway_HasNoLegacyDependency"/>,
+///     直接检查 Gateway 的构造依赖里不含任何 Legacy 取数组件 ——
+///     若有人把 Legacy 加回来,那条会红。
+///
+/// 其余用例(失败分类、不回落语义之外的错误码、行数上限)逐条保留。
 /// </summary>
 public class S8MonitoringDataGatewayTests
 {
@@ -39,25 +54,6 @@ public class S8MonitoringDataGatewayTests
         };
     }
 
-    /// <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;
@@ -86,119 +82,122 @@ public class S8MonitoringDataGatewayTests
         }
     }
 
-    private static S8MonitoringDataGateway Gateway(
-        IS8MonitoringDataProvider provider, SpyLegacyProvider legacy) =>
-        new(legacy,
-            new S8MonitoringDataProviderRegistry(new[] { provider }),
+    private static S8MonitoringDataGateway Gateway(IS8MonitoringDataProvider provider) =>
+        new(new S8MonitoringDataProviderRegistry(new[] { provider }),
             new S8DatasetCatalog(new[] { new FakeSource() }),
             NullLogger<S8MonitoringDataGateway>.Instance);
 
-    private static AdoS8WatchRule StandardRule() => new()
+    private static AdoS8WatchRule DatasetRule() => new()
     {
         Id = 1,
         RuleCode = "UT_GATEWAY_RULE",
         RuleType = "TIMEOUT",
-        DataAccessMode = S8DataAccessMode.StandardDataset,
-        DatasetCode = ReadyDataset,
-        // 刻意留一条可执行 SQL:若实现存在回落,这条会被执行,探针即被调用。
-        Expression = "SELECT 1"
+        DatasetCode = ReadyDataset
     };
 
     // ============================================================
-    // E. STANDARD_DATASET 绝不回落 Legacy
+    // 结构性断言:不存在 Legacy 取数依赖
     // ============================================================
 
+    /// <summary>
+    /// 取代原先散布在每条用例里的 <c>legacy.CallCount == 0</c>。
+    ///
+    /// 逐用例断言只能证明「这一次没回落」;本用例证明的是「回落这条路不存在」。
+    /// 判据是构造函数依赖 —— Gateway 若要执行 SQL,必须先拿到某个能执行 SQL 的东西。
+    /// </summary>
     [Fact]
-    public async Task StandardDataset_ProviderThrows_DoesNotFallBackToLegacySql()
+    public void Gateway_HasNoLegacyDependency()
     {
-        var legacy = new SpyLegacyProvider();
-        var gateway = Gateway(new ThrowingProvider(), legacy);
+        var ctor = Assert.Single(typeof(S8MonitoringDataGateway).GetConstructors());
+        var dependencies = ctor.GetParameters().Select(p => p.ParameterType.Name).ToArray();
+
+        foreach (var forbidden in new[]
+                 {
+                     "IS8LegacySqlDataProvider", "S8LegacySqlDataProvider",
+                     "S8DataSourceRowLoader", "S8SqlSugarScopeFactory", "ISqlSugarClient"
+                 })
+            Assert.DoesNotContain(forbidden, dependencies);
+
+        // 正向:三件依赖恰好是 Registry / Catalog / Logger。
+        Assert.Equal(3, dependencies.Length);
+        Assert.Contains("S8MonitoringDataProviderRegistry", dependencies);
+        Assert.Contains("IS8DatasetCatalog", dependencies);
+    }
 
-        var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            gateway.LoadAsync(1, 1, StandardRule(), "TIMEOUT", 60, 1000));
+    /// <summary>规则实体不得再带任何"自带 SQL / 自选物理数据源"的字段。</summary>
+    [Fact]
+    public void WatchRule_HasNoSqlOrDataSourceFields()
+    {
+        var props = typeof(AdoS8WatchRule).GetProperties().Select(p => p.Name).ToArray();
 
-        Assert.Equal("dataset_query_failed", ex.Reason);
-        Assert.Equal(0, legacy.CallCount);   // ← 本测试的核心断言
+        Assert.DoesNotContain("Expression", props);
+        Assert.DoesNotContain("DataSourceId", props);
+        Assert.DoesNotContain("DataAccessMode", props);
+        // 取数配置只剩这一个。
+        Assert.Contains("DatasetCode", props);
     }
 
+    // ============================================================
+    // 失败分类
+    // ============================================================
+
     [Fact]
-    public async Task StandardDataset_Success_DoesNotTouchLegacyProvider()
+    public async Task ProviderThrows_IsClassifiedAsQueryFailed()
     {
-        var legacy = new SpyLegacyProvider();
-        var gateway = Gateway(new OkProvider(), legacy);
-
-        var result = await gateway.LoadAsync(11, 22, StandardRule(), "TIMEOUT", 60, 1000);
+        var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
+            Gateway(new ThrowingProvider()).LoadAsync(1, 1, DatasetRule(), "TIMEOUT", 1000));
 
-        Assert.Single(result.RowSet.Rows);
-        Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode);
-        Assert.Equal(0, result.DataSourceId);
-        Assert.Equal(0, legacy.CallCount);
+        Assert.Equal(S8DatasetReasonCode.QueryFailed, ex.Reason);
     }
 
     [Fact]
-    public async Task StandardDataset_UndefinedDataset_FailsWithNotDefined_NoLegacyFallback()
+    public async Task UndefinedDataset_FailsWithNotDefined()
     {
-        var legacy = new SpyLegacyProvider();
-        var gateway = Gateway(new OkProvider(), legacy);
-        var rule = StandardRule();
+        var rule = DatasetRule();
         rule.DatasetCode = "NOT_IN_CATALOG";
 
         var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            gateway.LoadAsync(1, 1, rule, "TIMEOUT", 60, 1000));
+            Gateway(new OkProvider()).LoadAsync(1, 1, rule, "TIMEOUT", 1000));
 
         Assert.Equal(S8DatasetReasonCode.NotDefined, ex.Reason);
-        Assert.Equal(0, legacy.CallCount);
     }
 
     [Fact]
-    public async Task StandardDataset_InvalidScope_Fails_NoLegacyFallback()
+    public async Task MissingDatasetCode_FailsWithCodeMissing()
     {
-        var legacy = new SpyLegacyProvider();
-        var gateway = Gateway(new OkProvider(), legacy);
+        var rule = DatasetRule();
+        rule.DatasetCode = null;
 
-        await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            gateway.LoadAsync(0, 1, StandardRule(), "TIMEOUT", 60, 1000));
+        var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
+            Gateway(new OkProvider()).LoadAsync(1, 1, rule, "TIMEOUT", 1000));
 
-        Assert.Equal(0, legacy.CallCount);
+        Assert.Equal(S8DatasetReasonCode.CodeMissing, ex.Reason);
     }
 
-    // ============================================================
-    // F. Legacy 路径仍然可达
-    // ============================================================
-
     [Fact]
-    public async Task LegacyRule_RoutesToLegacyProvider()
+    public async Task InvalidScope_Fails()
     {
-        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);
+        await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
+            Gateway(new OkProvider()).LoadAsync(0, 1, DatasetRule(), "TIMEOUT", 1000));
     }
 
     // ============================================================
-    // 行数上限对两条路径同样生效
+    // 正常路径与行数上限
     // ============================================================
 
     [Fact]
-    public async Task RowLimitGuard_AppliesToStandardDatasetPath()
+    public async Task DatasetRule_LoadsCanonicalRows()
     {
-        var legacy = new SpyLegacyProvider();
-        var gateway = Gateway(new OkProvider(), legacy);
+        var result = await Gateway(new OkProvider()).LoadAsync(11, 22, DatasetRule(), "TIMEOUT", 1000);
+
+        Assert.Single(result.RowSet.Rows);
+    }
 
+    [Fact]
+    public async Task RowLimitGuard_Applies()
+    {
         var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            gateway.LoadAsync(1, 1, StandardRule(), "TIMEOUT", 60, maxRows: 0));
+            Gateway(new OkProvider()).LoadAsync(1, 1, DatasetRule(), "TIMEOUT", maxRows: 0));
 
         Assert.Equal("result_too_many_rows", ex.Reason);
     }

+ 6 - 4
server/Admin.NET.Test/S8/S8PurchaseDeliveryDatasetTests.cs

@@ -35,7 +35,6 @@ public class S8PurchaseDeliveryDatasetTests
     {
         RuleCode = "UT_PURCHASE_DELIVERY",
         RuleType = ruleType,
-        DataAccessMode = S8DataAccessMode.StandardDataset,
         DatasetCode = PurchaseDelivery
     };
 
@@ -224,10 +223,13 @@ public class S8PurchaseDeliveryDatasetTests
     }
 
     [Fact]
-    public void StandardDatasetRule_DoesNotRequireDataSource()
+    public void DataSourceRequirement_NoLongerExists()
     {
-        // dataSourceId = 0 之所以合法,靠的就是这一条;若回归成 true,创建规则会因"数据源不存在"失败。
-        Assert.False(S8WatchRuleDataAccessValidator.RequiresDataSource(StandardRule()));
+        // 原用例断言 RequiresDataSource(StandardRule()) == false ——
+        // 当时 Legacy 规则仍需绑定物理数据源,这条是"本数据集不受该约束"的护栏。
+        // S8-STANDARD-DATASET-HARD-CUTOVER-1 删除了整个物理数据源概念,
+        // 该判定方法随之消失。用反射断言它不会被加回来。
+        Assert.Null(typeof(S8WatchRuleDataAccessValidator).GetMethod("RequiresDataSource"));
     }
 
     /// <summary>

+ 24 - 57
server/Admin.NET.Test/S8/S8PurchaseDeliveryProviderMappingTests.cs

@@ -20,8 +20,7 @@ namespace Admin.NET.Test.S8;
 ///                              会过得了列校验、卡在 DedupKey 上,数据库静默截断会把两个 PO Line 折叠成一条异常。
 ///   E. due_at 双分支          → need_date 为 NULL 时回落 due_date;两者实测 18/36 行不同,不是等价列。
 ///   F. 判定归 evaluator       → Provider 不滤 status,"逾期但已完成不命中"必须由 evaluator 判出来。
-///   G. STANDARD 绝不回落      → 取数失败偷偷执行 rule.Expression 会让迁移失败被静默隐藏:
-///                              规则看起来在正常跑,实际跑的还是旧 SQL。
+///   G. 取数路径唯一           → Provider 取数失败必须暴露为 dataset_query_failed,而不是换一条路。
 ///
 /// 不接 DB、不接 DI、不触发任何真实规则。SQL 在真实库上的行为已由实施前的只读核验覆盖
 /// (4 租户身份唯一、跨租户交集 0、外层走 idx_stat_date),本文件锁定的是结构契约。
@@ -50,7 +49,6 @@ public class S8PurchaseDeliveryProviderMappingTests
         SourceObjectType = "PURCHASE_ORDER_LINE",
         Severity = "SERIOUS",
         RuleType = "TIMEOUT",
-        DataAccessMode = S8DataAccessMode.StandardDataset,
         DatasetCode = PurchaseDelivery
     };
 
@@ -302,7 +300,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, 0, new DateTime(2026, 9, 5));
+            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         var hit = Assert.Single(hits);
         Assert.Equal("PO202608260002#3", hit.SourceObjectId);
@@ -318,7 +316,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, 0, new DateTime(2026, 9, 5));
+            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         Assert.Empty(hits);
     }
@@ -333,7 +331,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, 0, new DateTime(2026, 9, 5));
+            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         Assert.Empty(hits);
     }
@@ -348,7 +346,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             Row("PO-CANCELLED", "1", new DateTime(2026, 8, 1), null, "CANCELLED"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
+            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         Assert.Empty(hits);
     }
@@ -360,7 +358,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, 0, new DateTime(2026, 9, 5));
+            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         Assert.Single(hits);
     }
@@ -377,9 +375,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, 0, new DateTime(2026, 9, 5));
+            before, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
         var hitsAfter = S8TimeoutRuleEvaluator.EvaluateRows(
-            after, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
+            after, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
 
         var dedupKey = Assert.Single(hitsBefore).DedupKey;
         Assert.Empty(hitsAfter);
@@ -393,7 +391,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, 0, new DateTime(2026, 9, 5)));
+            rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5)));
 
         Assert.Equal("PURCHASE_DELIVERY_ABNORMAL", hit.ExceptionTypeCode);
         Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
@@ -409,64 +407,33 @@ public class S8PurchaseDeliveryProviderMappingTests
     }
 
     // ============================================================
-    // G. STANDARD_DATASET 绝不回落 Legacy
+    // G. 取数路径唯一
     // ============================================================
+    //
+    // 原 T12 / T12b 断言「STANDARD_DATASET 绝不回落 Legacy」,做法是注入一个
+    // SpyLegacy 探针并断言 CallCount == 0,同时在 rule.Expression 里塞一条
+    // "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN" 当诱饵。
+    //
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1 之后这两条已无法成立、也无需成立:
+    //   · IS8LegacySqlDataProvider 不存在 → 探针类型写不出来;
+    //   · AdoS8WatchRule.Expression / DataSourceId 不存在 → 诱饵放不进去。
+    // 「不回落」从一条需要反复验证的运行期行为,变成了类型系统层面的事实。
+    //
+    // 结构性替代断言见 S8MonitoringDataGatewayTests.Gateway_HasNoLegacyDependency
+    // 与 WatchRule_HasNoSqlOrDataSourceFields;此处只保留失败分类这一条仍有意义的行为。
 
     [Fact]
-    public async Task T12_StandardDataset_NeverFallsBackToLegacySql()
+    public async Task T12_ProviderFailure_SurfacesAsQueryFailed()
     {
-        var legacy = new SpyLegacy();
         var gateway = new S8MonitoringDataGateway(
-            legacy,
-            new S8MonitoringDataProviderRegistry(new IS8MonitoringDataProvider[] { new EmptyPurchaseDeliveryProvider() }),
-            new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }),
-            NullLogger<S8MonitoringDataGateway>.Instance);
-
-        var rule = NewRule();
-        // 历史遗留字段里即使躺着可执行 SQL,STANDARD_DATASET 路径也绝不能碰它。
-        rule.Expression = "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN";
-        rule.DataSourceId = 99;
-
-        var result = await gateway.LoadAsync(838257186181189, 838257186320453, rule, "TIMEOUT", 60, 1000);
-
-        Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode);
-        Assert.Equal(0, legacy.CallCount);
-        Assert.Null(legacy.LastExpression);
-    }
-
-    [Fact]
-    public async Task T12b_ProviderFailure_SurfacesAsQueryFailed_WithoutLegacyFallback()
-    {
-        var legacy = new SpyLegacy();
-        var gateway = new S8MonitoringDataGateway(
-            legacy,
             new S8MonitoringDataProviderRegistry(new IS8MonitoringDataProvider[] { new ThrowingPurchaseDeliveryProvider() }),
             new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }),
             NullLogger<S8MonitoringDataGateway>.Instance);
 
-        var rule = NewRule();
-        rule.Expression = "SELECT SHOULD_NEVER_RUN";
-
         var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            gateway.LoadAsync(838257186181189, 838257186320453, rule, "TIMEOUT", 60, 1000));
+            gateway.LoadAsync(838257186181189, 838257186320453, NewRule(), "TIMEOUT", 1000));
 
         Assert.Equal(S8DatasetReasonCode.QueryFailed, ex.Reason);
-        Assert.Equal(0, legacy.CallCount);   // ← 本测试的核心断言
-    }
-
-    private sealed class SpyLegacy : IS8LegacySqlDataProvider
-    {
-        public int CallCount { get; private set; }
-        public string LastExpression { get; private set; }
-
-        public Task<S8MonitoringDataResult> LoadAsync(
-            long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
-            int timeoutSeconds, CancellationToken cancellationToken = default)
-        {
-            CallCount++;
-            LastExpression = rule.Expression;
-            return Task.FromResult(new S8MonitoringDataResult { RowSet = S8MonitoringRowSet.Empty });
-        }
     }
 
     private sealed class EmptyPurchaseDeliveryProvider : IS8MonitoringDataProvider

+ 26 - 83
server/Admin.NET.Test/S8/S8WatchRuleDataAccessValidatorTests.cs

@@ -7,11 +7,18 @@ using Xunit;
 namespace Admin.NET.Test.S8;
 
 /// <summary>
-/// 规则取数配置校验。
+/// 规则取数配置校验(T1–T4)
 ///
-/// S8-DATASET-FOUNDATION-HARDENING-2 起分两级:
-///   · ValidateForSave   —— 结构 + 目录归属,不要求 Provider 已上线(允许存草稿);
+/// 分两级:
+///   · ValidateForSave   —— dataset_code 结构 + 目录归属,不要求 Provider 已上线(允许存草稿);
 ///   · ValidateForEnable —— 完整运行条件(委托 Enable Gate)。
+///
+/// S8-STANDARD-DATASET-HARD-CUTOVER-1:原有的 Legacy 分组用例
+/// (mode=NULL 可保存 / 缺 expression 被拒 / 不得混填 dataset_code /
+///   RequiresDataSource / UnknownDataAccessMode)已全部删除 ——
+/// 它们校验的是"两种取数模式之间的边界",而现在只剩一种模式。
+/// 那些字段(DataAccessMode / Expression / DataSourceId)连同校验分支一并物理消失,
+/// 用例既写不出来、也无对应风险可守。
 /// </summary>
 public class S8WatchRuleDataAccessValidatorTests
 {
@@ -60,135 +67,71 @@ public class S8WatchRuleDataAccessValidatorTests
     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()
+    private static AdoS8WatchRule Rule(string ruleType, string datasetCode = null) => new()
     {
         RuleCode = "UT_RULE",
         RuleType = ruleType,
-        DataAccessMode = mode,
-        DatasetCode = datasetCode,
-        Expression = expression
+        DatasetCode = datasetCode
     };
 
-    // ---------- Legacy ----------
-
-    [Fact]
-    public void LegacyRule_WithNullMode_AndExpression_IsAccepted()
-    {
-        // 132 条历史规则的形态:mode=NULL、dataset_code=NULL、expression 有值。
-        S8WatchRuleDataAccessValidator.ValidateForSave(
-            Rule("TIMEOUT", mode: null, datasetCode: null, expression: "SELECT 1"), Catalog());
-    }
-
-    [Fact]
-    public void LegacyRule_WithoutExpression_IsRejected()
-    {
-        var ex = Assert.Throws<S8BizException>(() =>
-            S8WatchRuleDataAccessValidator.ValidateForSave(Rule("TIMEOUT", expression: null), Catalog()));
-        Assert.Contains("expression", ex.Message);
-    }
-
-    [Fact]
-    public void LegacyRule_MustNotCarryDatasetCode()
-    {
-        // 两种模式的字段混填会让实际生效路径不可预测,必须显式拒绝。
-        var rule = Rule("TIMEOUT", mode: S8DataAccessMode.LegacySql, datasetCode: ReadyDataset, expression: "SELECT 1");
-        Assert.Throws<S8BizException>(() => S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog()));
-    }
-
-    [Fact]
-    public void LegacyRule_IsRejectedByEnableGate_AfterRuntimeRetirement()
-    {
-        // 语义变更(S8-LEGACY-RUNTIME-RETIREMENT-1):本用例原名
-        // LegacyRule_PassesEnableGate_WithoutDatasetChecks,断言 Legacy 规则可以被启用。
-        // 该行为已被产品裁决取代 —— STANDARD_DATASET 是唯一允许运行的取数模式。
-        // 这不是实现回归,而是断言的业务前提本身已经改变,故就地反转而非删除,
-        // 以保留「Legacy 曾可启用」这段历史在 git blame 中可追。
-        //
-        // 用 mode: null 而非显式 LEGACY_SQL 是刻意的:132 条历史规则正是 NULL,
-        // 必须证明退役门禁覆盖的是**解析后的模式**,而不只是字面值。
-        var rule = Rule("TIMEOUT", mode: null, expression: "SELECT 1");
-        var ex = Assert.Throws<S8BizException>(() =>
-            S8WatchRuleDataAccessValidator.ValidateForEnable(rule, Gate(), 1, 1));
-        Assert.Contains(S8RuleCreationPolicy.LegacySqlRetiredReason, ex.Message);
-    }
-
-    // ---------- Standard dataset · Save ----------
+    // ---------- T1 / T2 / T3:保存级 ----------
 
     [Fact]
-    public void StandardDatasetRule_WithoutDatasetCode_IsRejectedOnSave()
+    public void DatasetRule_WithoutDatasetCode_IsRejectedOnSave()
     {
         var ex = Assert.Throws<S8BizException>(() =>
             S8WatchRuleDataAccessValidator.ValidateForSave(
-                Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset), Catalog()));
+                Rule("TIMEOUT"), Catalog()));
         Assert.Contains(S8DatasetReasonCode.CodeMissing, ex.Message);
     }
 
     [Fact]
-    public void StandardDatasetRule_WithUndefinedDataset_IsRejectedOnSave()
+    public void DatasetRule_WithUndefinedDataset_IsRejectedOnSave()
     {
         var ex = Assert.Throws<S8BizException>(() =>
             S8WatchRuleDataAccessValidator.ValidateForSave(
-                Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: "NO_SUCH_DATASET"), Catalog()));
+                Rule("TIMEOUT", "NO_SUCH_DATASET"), Catalog()));
         Assert.Contains(S8DatasetReasonCode.NotDefined, ex.Message);
     }
 
     [Fact]
-    public void StandardDatasetRule_DefinedButProviderMissing_CanBeSavedAsDraft()
+    public void DatasetRule_DefinedButProviderMissing_CanBeSavedAsDraft()
     {
         // 关键行为:Provider 未上线时仍可保存配置。
         S8WatchRuleDataAccessValidator.ValidateForSave(
-            Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: DefinedNoProvider), Catalog());
+            Rule("TIMEOUT", DefinedNoProvider), Catalog());
     }
 
-    // ---------- Standard dataset · Enable ----------
+    // ---------- T3 / T4:启用级 ----------
 
     [Fact]
-    public void StandardDatasetRule_DefinedButProviderMissing_CannotBeEnabled()
+    public void DatasetRule_DefinedButProviderMissing_CannotBeEnabled()
     {
         var ex = Assert.Throws<S8BizException>(() =>
             S8WatchRuleDataAccessValidator.ValidateForEnable(
-                Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: DefinedNoProvider),
+                Rule("TIMEOUT", DefinedNoProvider),
                 Gate(), 1, 1));
         Assert.Contains(S8DatasetReasonCode.ProviderNotRegistered, ex.Message);
     }
 
     [Fact]
-    public void StandardDatasetRule_WithCapabilityMismatch_CannotBeEnabled()
+    public void DatasetRule_WithCapabilityMismatch_CannotBeEnabled()
     {
         // TIMEOUT-only 数据集挂 OUT_OF_RANGE 规则。
         var ex = Assert.Throws<S8BizException>(() =>
             S8WatchRuleDataAccessValidator.ValidateForEnable(
-                Rule("OUT_OF_RANGE", mode: S8DataAccessMode.StandardDataset, datasetCode: ReadyDataset),
+                Rule("OUT_OF_RANGE", ReadyDataset),
                 Gate(), 1, 1));
         Assert.Contains(S8DatasetReasonCode.CapabilityMismatch, ex.Message);
     }
 
     [Fact]
-    public void StandardDatasetRule_Matching_IsEnabled_WithoutAnySql()
+    public void DatasetRule_Matching_IsEnabled_WithoutAnySql()
     {
         // 标准数据集规则不需要用户填写任何 SQL。
-        var rule = Rule("TIMEOUT", mode: S8DataAccessMode.StandardDataset, datasetCode: ReadyDataset, expression: null);
+        var rule = Rule("TIMEOUT", ReadyDataset);
         S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog());
         S8WatchRuleDataAccessValidator.ValidateForEnable(rule, Gate(), 1, 1);
     }
 
-    // ---------- 其他 ----------
-
-    [Fact]
-    public void UnknownDataAccessMode_IsRejected()
-    {
-        Assert.Throws<S8BizException>(() =>
-            S8WatchRuleDataAccessValidator.ValidateForSave(
-                Rule("TIMEOUT", mode: "SOMETHING_ELSE", expression: "SELECT 1"), Catalog()));
-    }
-
-    [Fact]
-    public void RequiresDataSource_TrueForLegacy_FalseForStandardDataset()
-    {
-        // LEGACY_SQL 仍必须绑定可信作用域内的数据源;STANDARD_DATASET 无物理数据源概念。
-        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: ReadyDataset)));
-    }
 }

+ 6 - 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.486</AssemblyVersion>
-    <FileVersion>1.0.486</FileVersion>
-    <Version>1.0.486</Version>
+    <AssemblyVersion>1.0.487</AssemblyVersion>
+    <FileVersion>1.0.487</FileVersion>
+    <Version>1.0.487</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -679,6 +679,9 @@
     <None Update="UpdateScripts\1.0.486.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.487.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 126 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.487.sql

@@ -0,0 +1,126 @@
+-- S8-STANDARD-DATASET-HARD-CUTOVER-1 · 步骤 2:删除 Legacy 取数体系的全部残留。
+--
+-- 最终架构(唯一路径):
+--   Data Platform → Governed Dataset → S8 Provider → Canonical Contract
+--                 → Watch Rule → Evaluator → Detection → Exception
+-- S8 不维护物理数据源、不执行任意 SQL、不保留 Legacy Rule。
+--
+-- 本脚本是**破坏性**的,按依赖顺序执行。执行前的实测基线(2026-09-05):
+--   ado_s8_watch_rule    : 134 行 = Legacy 132(data_access_mode IS NULL)+ Standard 2
+--   Legacy 中 enabled=1  : 1 行(id=86 RULE_S1_ORDER_DELIVERY_DUE_GRAY30_DATE_DELAY)
+--   ado_s8_data_source   : 3 行(无 FK / 无触发器 / 无视图 / 除 PRIMARY 无索引)
+--   Legacy 规则的运行产物: detection_log 327 行、exception 34 行
+--                          (34 条中 32 条状态 NEW 从未被认领,2 条为 UAT 测试单;
+--                            40 条 timeline ≈ 每单 1.2 条,即仅创建事件,无真实处理链)
+--
+-- ⚠️ 判定 Legacy 用「非 STANDARD_DATASET」补集写法,且必须显式写 IS NULL:
+--    `data_access_mode <> 'STANDARD_DATASET'` 对 NULL 求值为 UNKNOWN,
+--    会漏掉全部 132 条历史行。这是本脚本最容易写错的一处。
+--    补集写法同时覆盖脏值(如 'SQL'),与代码侧「非 STANDARD 即无运行资格」对齐。
+--
+-- ⚠️⚠️ rule_code 跨租户重名,且 Rule 01 正在其中:
+--    RULE_S4_PURCHASE_DELIVERY_DATE_DELAY 同时存在于
+--      id=46(租户 797403760988230,Legacy)
+--      id=144(租户 838257237606469,Legacy)
+--      id=1329909460023(租户 838257186181189,STANDARD_DATASET ← Rule 01,必须保留)
+--    而 ado_s8_rule_detection_state **只有 rule_code、没有 rule_id**。
+--    因此凡按 rule_code 定位的删除,一律附加 (tenant_id, factory_id) 限定,
+--    绝不允许出现裸 `WHERE rule_code = ...`。
+--
+-- 保留:Rule 01(1329909460023)与诊断规则(1329909460024),
+--       两者均为 STANDARD_DATASET / PURCHASE_DELIVERY / enabled=0,本脚本不触碰。
+--
+-- 幂等:DELETE 带条件、DROP 带 IF EXISTS、ALTER 前查 information_schema。
+--       重复执行影响行数为 0。
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 0. 固定 Legacy 规则 id 集合(后续步骤全部引用它,避免各步判定漂移)
+-- ────────────────────────────────────────────────────────────────────────────
+DROP TEMPORARY TABLE IF EXISTS tmp_s8_legacy_rule;
+CREATE TEMPORARY TABLE tmp_s8_legacy_rule AS
+SELECT id, tenant_id, factory_id, rule_code
+  FROM ado_s8_watch_rule
+ WHERE data_access_mode IS NULL
+    OR TRIM(data_access_mode) = ''
+    OR UPPER(TRIM(data_access_mode)) <> 'STANDARD_DATASET';
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 1. Legacy 规则产生的异常单及其子表
+--    无 FK,必须手工按父子顺序删;子表一律经 exception_id 关联。
+-- ────────────────────────────────────────────────────────────────────────────
+DROP TEMPORARY TABLE IF EXISTS tmp_s8_legacy_exception;
+CREATE TEMPORARY TABLE tmp_s8_legacy_exception AS
+SELECT e.id FROM ado_s8_exception e
+  JOIN tmp_s8_legacy_rule r ON r.id = e.source_rule_id;
+
+DELETE t FROM ado_s8_exception_timeline t JOIN tmp_s8_legacy_exception x ON x.id = t.exception_id;
+DELETE d FROM ado_s8_decision_record   d JOIN tmp_s8_legacy_exception x ON x.id = d.exception_id;
+DELETE v FROM ado_s8_evidence          v JOIN tmp_s8_legacy_exception x ON x.id = v.exception_id;
+DELETE n FROM ado_s8_notification_log  n JOIN tmp_s8_legacy_exception x ON x.id = n.exception_id;
+DELETE e FROM ado_s8_exception         e JOIN tmp_s8_legacy_exception x ON x.id = e.id;
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 2. Legacy 规则的检测轨迹与抗抖状态
+--    detection_log 有 rule_id,可精确关联;
+--    rule_detection_state 只有 rule_code,故必须三键(tenant + factory + code)限定 ——
+--    否则会连带删掉 Rule 01 的抗抖状态(见文件头的重名说明)。
+-- ────────────────────────────────────────────────────────────────────────────
+DELETE d FROM ado_s8_detection_log d JOIN tmp_s8_legacy_rule r ON r.id = d.rule_id;
+
+DELETE s FROM ado_s8_rule_detection_state s
+  JOIN tmp_s8_legacy_rule r
+    ON r.tenant_id = s.tenant_id
+   AND r.factory_id = s.factory_id
+   AND r.rule_code = s.rule_code;
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 3. Legacy 规则本体
+-- ────────────────────────────────────────────────────────────────────────────
+DELETE w FROM ado_s8_watch_rule w JOIN tmp_s8_legacy_rule r ON r.id = w.id;
+
+DROP TEMPORARY TABLE IF EXISTS tmp_s8_legacy_exception;
+DROP TEMPORARY TABLE IF EXISTS tmp_s8_legacy_rule;
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 4. 规则表的 Legacy 列
+--    必须在第 3 步之后:data_access_mode 正是第 0 步的判定依据。
+--    用 information_schema 预检使脚本可重复执行(MySQL 的 DROP COLUMN 无 IF EXISTS)。
+-- ────────────────────────────────────────────────────────────────────────────
+SET @sql := (
+  SELECT IF(COUNT(*) > 0, 'ALTER TABLE ado_s8_watch_rule DROP COLUMN data_source_id', 'DO 0')
+    FROM information_schema.COLUMNS
+   WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_watch_rule' AND COLUMN_NAME = 'data_source_id');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+  SELECT IF(COUNT(*) > 0, 'ALTER TABLE ado_s8_watch_rule DROP COLUMN expression', 'DO 0')
+    FROM information_schema.COLUMNS
+   WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_watch_rule' AND COLUMN_NAME = 'expression');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+  SELECT IF(COUNT(*) > 0, 'ALTER TABLE ado_s8_watch_rule DROP COLUMN data_access_mode', 'DO 0')
+    FROM information_schema.COLUMNS
+   WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_watch_rule' AND COLUMN_NAME = 'data_access_mode');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 5. 数据源整表
+--    该表 endpoint 列存的是含凭据的真实连接串;S8 已不再维护物理连接,直接删除。
+-- ────────────────────────────────────────────────────────────────────────────
+DROP TABLE IF EXISTS ado_s8_data_source;
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 6. 菜单与授权
+--    先清 SysRoleMenu 授权行,再删菜单,避免留下悬挂授权。
+--    「维护数据源」按钮权限点(s8:config:data-source)一并清除;
+--    目录侧保留一条 Deprecated 墓碑,防止该码被复用。
+-- ────────────────────────────────────────────────────────────────────────────
+DELETE rm FROM SysRoleMenu rm
+  JOIN SysMenu m ON m.Id = rm.MenuId
+ WHERE m.Name = 'aidopS8DataSourceConfig'
+    OR m.Permission = 's8:config:data-source';
+
+DELETE FROM SysMenu
+ WHERE Name = 'aidopS8DataSourceConfig'
+    OR Permission = 's8:config:data-source';

+ 6 - 2
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8AuthorizationGuardTests.cs

@@ -74,7 +74,11 @@ public class S8AuthorizationGuardTests
     public void AllS8ControllersAreDiscovered()
     {
         var controllers = S8Controllers.ToList();
-        Assert.True(controllers.Count >= 25, $"仅发现 {controllers.Count} 个 S8 Controller,疑似枚举失效");
+        // 下限随功能删除同步下调:S8-STANDARD-DATASET-HARD-CUTOVER-1 物理删除了
+        // AdoS8ConfigDataSourcesController 与 AdoS8ConfigAlertRulesController(25 → 23)。
+        // 这条断言防的是「反射枚举失效导致后面所有授权检查空跑」,不是控制器数量本身,
+        // 故取当前实际数量作下限即可。
+        Assert.True(controllers.Count >= 23, $"仅发现 {controllers.Count} 个 S8 Controller,疑似枚举失效");
         Assert.Contains(controllers, t => t == typeof(AdoS8ExceptionsController));
         Assert.Contains(controllers, t => t == typeof(AdoS8WatchDebugController));
     }
@@ -127,7 +131,7 @@ public class S8AuthorizationGuardTests
     /// <summary>配置类 Controller 的写动作必须落在 config 组,不得使用 operator / verification 能力。</summary>
     [Theory]
     [InlineData(typeof(AdoS8ConfigWatchRulesController), S8PermissionCatalog.ConfigWatchRule)]
-    [InlineData(typeof(AdoS8ConfigDataSourcesController), S8PermissionCatalog.ConfigDataSource)]
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1:AdoS8ConfigDataSourcesController 已物理删除。
     [InlineData(typeof(AdoS8ConfigExceptionTypesController), S8PermissionCatalog.ConfigExceptionType)]
     [InlineData(typeof(AdoS8ConfigNotificationLayersController), S8PermissionCatalog.ConfigNotification)]
     [InlineData(typeof(AdoS8ConfigScenesController), S8PermissionCatalog.ConfigScene)]

+ 0 - 166
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DataSourceWriteRetiredTests.cs

@@ -1,166 +0,0 @@
-using System.Runtime.CompilerServices;
-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.Service.S8;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using Xunit;
-
-namespace Admin.NET.Plugin.AiDOP.Tests.S8;
-
-/// <summary>
-/// S8-LEGACY-RUNTIME-RETIREMENT-1(TASK B):钉住 CFG_DATASRC 的
-/// 「历史只读 + 写入口退役 + 不再发起外连」契约。
-///
-/// 产品裁决:S8 = 数据中台消费层。物理数据源(连接串 / 主机 / 凭据)归数据平台治理,
-/// 不由租户 S8 管理员维护。只隐藏 UI 不够 —— API 仍可直接调用,
-/// 而 <c>ado_s8_data_source.endpoint</c> 存的是含凭据的真实连接串。
-///
-/// <para><b>不触任何数据库。</b> 这不是靠 mock 硬凑出来的——写方法在**任何 DB 访问之前**
-/// 就抛出,所以根本不需要可用的仓储。沿用 <see cref="S8AlertRuleWriteRetiredTests"/> 的
-/// <see cref="RuntimeHelpers.GetUninitializedObject"/> 范式绕开构造函数
-/// (其参数 <c>SqlSugarRepository&lt;T&gt;</c> 的无参构造会触发 Furion.App 静态初始化,
-/// 在无宿主的 xunit 进程内必抛 TypeInitializationException)。
-/// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 而失败——
-/// 这正是我们想要的信号:**守卫必须前置**。</para>
-/// </summary>
-public class S8DataSourceWriteRetiredTests
-{
-    private static S8DataSourceService Svc() =>
-        (S8DataSourceService)RuntimeHelpers.GetUninitializedObject(typeof(S8DataSourceService));
-
-    private static readonly S8TrustedScope Scope = new(838257186181189L, 838257186320453L);
-
-    private static AdoS8ConfigDataSourcesController Controller() => new(Svc(), null!);
-
-    private static AdoS8DataSource Body() => new() { DataSourceCode = "UT_DS", Type = "SQL" };
-
-    private static void AssertGone(IActionResult result)
-    {
-        var objectResult = Assert.IsType<ObjectResult>(result);
-        Assert.Equal(StatusCodes.Status410Gone, objectResult.StatusCode);
-        var message = objectResult.Value!.GetType().GetProperty("message")!.GetValue(objectResult.Value) as string;
-        Assert.Equal(S8DataSourceService.RetiredMessage, message);
-    }
-
-    // ============================================================
-    // Service 层:四个写入口全部退役
-    // ============================================================
-
-    [Fact]
-    public void CreateAsync_IsRetired()
-    {
-        var ex = Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(Body(), Scope); });
-        Assert.Equal(S8DataSourceService.RetiredMessage, ex.Message);
-    }
-
-    [Fact]
-    public void UpdateAsync_IsRetired()
-    {
-        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().UpdateAsync(1L, Body(), Scope); });
-    }
-
-    [Fact]
-    public void DeleteAsync_IsRetired()
-    {
-        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(1L, Scope); });
-    }
-
-    /// <summary>
-    /// 连接测试同样退役 —— 它会用库中 endpoint **真实建立外部数据库连接**,
-    /// 正是本批要终止的「S8 维护物理连接」能力,比单纯的行更新更需要关掉。
-    /// </summary>
-    [Fact]
-    public void TestAsync_IsRetired()
-    {
-        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().TestAsync(1L, Scope); });
-    }
-
-    /// <summary>越权 / 不存在的 Id 同样返回「已退役」,不得因 Id 不同而走回 404 分支。</summary>
-    [Theory]
-    [InlineData(0L)]
-    [InlineData(1L)]            // 797 租户实际存在的 G01_TEST_DS
-    [InlineData(5L)]            // UAT 租户实际存在的 UATA-A01-DS
-    [InlineData(999999999L)]    // 不存在
-    public void Writes_AreRetired_RegardlessOfId(long id)
-    {
-        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(id, Scope); });
-        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().UpdateAsync(id, Body(), Scope); });
-        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().TestAsync(id, Scope); });
-    }
-
-    // ============================================================
-    // Controller 层:410 Gone
-    // ============================================================
-
-    [Fact]
-    public async Task Controller_Create_Returns410_WithoutScopeOrRepositoryAccess()
-    {
-        // 注意 controller 的 S8TrustedScopeResolver 传的是 null:
-        // 退役入口不得再去解析租户上下文,否则这里会 NullReferenceException。
-        AssertGone(await Controller().CreateAsync(Body()));
-    }
-
-    [Theory]
-    [InlineData(1L)]
-    [InlineData(999999999L)]
-    public async Task Controller_MutatingEndpoints_Return410_RegardlessOfId(long id)
-    {
-        var controller = Controller();
-        AssertGone(await controller.UpdateAsync(id, Body()));
-        AssertGone(await controller.DeleteAsync(id));
-        AssertGone(await controller.TestAsync(id));
-    }
-
-    // ============================================================
-    // 读路径与结构保留
-    // ============================================================
-
-    /// <summary>读路径必须保留:存量 Legacy 规则仍绑着这些行,排障与历史核对需要能查。</summary>
-    [Fact]
-    public void ReadPath_IsPreserved()
-    {
-        Assert.NotNull(typeof(S8DataSourceService).GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
-    }
-
-    /// <summary>
-    /// 本批只做 Runtime Retirement,不做物理清理:
-    /// 实体与 Legacy 取数链必须仍然存在,否则存量规则的历史轨迹会断。
-    /// </summary>
-    [Fact]
-    public void LegacyBackendTypes_AreRetained_NotDeleted()
-    {
-        Assert.NotNull(typeof(AdoS8DataSource));
-        Assert.NotNull(typeof(S8DataSourceService));
-    }
-
-    /// <summary>
-    /// 结构性保证:服务不再持有任何能发起外部连接的依赖。
-    /// 「约定不用」会随时间失效,「拿不到工厂」不会。
-    /// </summary>
-    [Fact]
-    public void Service_NoLongerHoldsConnectionFactory()
-    {
-        var fieldTypes = typeof(S8DataSourceService)
-            .GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
-            .Select(f => f.FieldType.Name)
-            .ToArray();
-
-        Assert.DoesNotContain("S8SqlSugarScopeFactory", fieldTypes);
-    }
-
-    /// <summary>
-    /// 文案必须说明「由数据平台治理」这一裁决,且不泄漏内部实现细节。
-    /// 尤其不能出现连接串片段 —— 该表 endpoint 列存的正是含凭据的真实连接串。
-    /// </summary>
-    [Fact]
-    public void RetiredMessage_StatesDataPlatformAuthority_AndLeaksNothing()
-    {
-        var m = S8DataSourceService.RetiredMessage;
-        Assert.Contains("数据平台", m);
-        Assert.Contains("Dataset", m);
-        foreach (var leak in new[] { "Exception", "SqlSugar", "ado_s8_", "Server=", "Password", "Pwd", "null" })
-            Assert.DoesNotContain(leak, m, StringComparison.OrdinalIgnoreCase);
-    }
-}

+ 14 - 74
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantIsolationContractTests.cs

@@ -71,11 +71,10 @@ public class S8TenantIsolationContractTests
     /// 缺参数即编译期失败,杜绝「按裸 Id 改 / 删 / 重归属」回归。
     /// </summary>
     [Theory]
-    // S8-STANDARD-DATASET-HARD-CUTOVER-1:S8AlertRuleService / AdoS8AlertRule 已物理删除,
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1:AlertRule 与 DataSource 两组服务/实体已物理删除,
     // 其条目随之移除——类型都不存在了,「写入口必须带作用域」自然无从违反。
-    // 功能的消失本身由 S8LegacySymbolsRemovedTests 断言。
+    // 这些功能的消失本身由 S8LegacySymbolsRemovedTests 断言。
     [InlineData(typeof(S8SceneConfigService), typeof(AdoS8SceneConfig))]
-    [InlineData(typeof(S8DataSourceService), typeof(AdoS8DataSource))]
     [InlineData(typeof(S8NotificationLayerService), typeof(AdoS8NotificationLayer))]
     [InlineData(typeof(S8RoleConfigService), typeof(AdoS8RolePermissionConfig))]
     [InlineData(typeof(S8DashboardCellConfigService), typeof(AdoS8DashboardCellConfig))]
@@ -83,12 +82,10 @@ public class S8TenantIsolationContractTests
     [InlineData(typeof(S8WatchRuleService), typeof(AdoS8WatchRule))]
     public void ConfigWrites_RequireTrustedScope(Type serviceType, Type entityType)
     {
-        // CreateAsync 用「前两个形参匹配」而非精确签名匹配:
-        // S8-LEGACY-SQL-RESIDUAL-CLEANUP-3 给 S8WatchRuleService.CreateAsync 追加了
-        // 可选形参 origin(string origin = S8RuleCreationOrigin.ExternalApi),
-        // 精确 2 参的 GetMethod 会返回 null,导致本用例误报。
-        // 真正要保证的契约是「entity + S8TrustedScope 必须显式出现在最前」,
-        // 后置可选形参不破坏该契约,故放宽为前缀匹配。
+        // CreateAsync 用「前两个形参匹配」而非精确签名匹配。
+        // 触发原因(origin 可选形参)已随 S8-STANDARD-DATASET-HARD-CUTOVER-1 消失,
+        // 但前缀匹配予以保留:真正要保证的契约是「entity + S8TrustedScope 必须显式出现在最前」,
+        // 后置可选形参不破坏该契约。改回精确匹配只会让下一次同类扩展再次误报。
         Assert.True(
             HasScopedWriteEntry(serviceType, "CreateAsync", entityType),
             $"{serviceType.Name}.CreateAsync 必须以 ({entityType.Name}, S8TrustedScope) 开头");
@@ -103,7 +100,7 @@ public class S8TenantIsolationContractTests
     }
 
     /// <summary>
-    /// 写入口是否以 (entity, S8TrustedScope) 开头。允许其后存在形参(如可选的 origin)
+    /// 写入口是否以 (entity, S8TrustedScope) 开头。允许其后存在形参,
     /// 但作用域必须是第 2 个形参且不可省略——调用方无法只传实体就完成写入。
     /// </summary>
     private static bool HasScopedWriteEntry(Type serviceType, string methodName, Type entityType) =>
@@ -196,70 +193,13 @@ public class S8TenantIsolationContractTests
             "ResolveTrustedScopeAsync", BindingFlags.Instance | BindingFlags.NonPublic));
     }
 
-    /// <summary>
-    /// 数据源响应不得出现明文密码:Pwd= / Password= 一律脱敏,其它连接字段保留以便同租户配置管理员识别。
-    /// </summary>
-    [Theory]
-    [InlineData("Server=h;Database=d;Uid=u;Pwd=S3cr3tValue;SslMode=None;")]
-    [InlineData("Server=h;Database=d;User Id=u;Password=S3cr3tValue;Encrypt=false;")]
-    [InlineData("server=h;pwd=S3cr3tValue")]
-    public void DataSourceEndpoint_NeverExposesPlaintextSecret(string endpoint)
-    {
-        var mask = typeof(S8DataSourceService).GetMethod(
-            "MaskSecret", BindingFlags.Static | BindingFlags.NonPublic);
-        Assert.NotNull(mask);
-
-        var masked = (string)mask!.Invoke(null, [endpoint]);
-        Assert.DoesNotContain("S3cr3tValue", masked);
-        Assert.Contains("******", masked);
-        // 非敏感字段保留(供同租户管理员识别是哪一条连接)。
-        Assert.Contains("Server=h", masked, StringComparison.OrdinalIgnoreCase);
-    }
-
-    /// <summary>
-    /// 原用例 DataSourceEndpoint_MaskedPlaceholderDoesNotOverwriteStoredSecret 断言
-    /// <c>MergeEndpointPreservingSecret</c> 在前端回填 "******" 时保留库中真实密码。
-    ///
-    /// S8-LEGACY-RUNTIME-RETIREMENT-1 之后该辅助方法连同 <c>UpdateAsync</c> 的实现一并移除:
-    /// 数据源写入口全部退役,**不存在任何能改写 endpoint 的路径**。
-    /// 原保障("别把占位符当密码写进去")因此被更强的保障取代 ——
-    /// 不是"写的时候小心",而是"根本不能写"。
-    ///
-    /// 用反射断言写方法不可绕过,而不是简单删掉本用例:删掉会让
-    /// "凭据不会被覆盖" 这条安全属性失去看守,将来有人恢复写入口时无人报警。
-    /// </summary>
-    [Fact]
-    public void DataSourceEndpoint_CannotBeOverwritten_BecauseAllWritesAreRetired()
-    {
-        // 写方法必须仍然存在(签名契约),但必须在任何 DB 访问前抛退役异常。
-        var svc = (S8DataSourceService)System.Runtime.CompilerServices.RuntimeHelpers
-            .GetUninitializedObject(typeof(S8DataSourceService));
-        var scope = new S8TrustedScope(838257186181189L, 838257186320453L);
-
-        Assert.Throws<S8WriteRetiredException>(() =>
-        {
-            _ = svc.UpdateAsync(1L, new AdoS8DataSource
-            {
-                DataSourceCode = "UT_DS",
-                Type = "SQL",
-                Endpoint = "Server=h;Uid=u;Pwd=AttackerSuppliedValue;"
-            }, scope);
-        });
-
-        Assert.Throws<S8WriteRetiredException>(() =>
-        {
-            _ = svc.CreateAsync(new AdoS8DataSource
-            {
-                DataSourceCode = "UT_DS",
-                Type = "SQL",
-                Endpoint = "Server=h;Uid=u;Pwd=AttackerSuppliedValue;"
-            }, scope);
-        });
-
-        // 合并辅助方法已随写路径一并移除;若它重新出现,说明写路径可能被恢复,需要复核。
-        Assert.Null(typeof(S8DataSourceService).GetMethod(
-            "MergeEndpointPreservingSecret", BindingFlags.Static | BindingFlags.NonPublic));
-    }
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1:此处原有两条数据源用例
+    //   · DataSourceEndpoint_NeverExposesPlaintextSecret(连接串脱敏)
+    //   · DataSourceEndpoint_CannotBeOverwritten_BecauseAllWritesAreRetired(写入口已退役)
+    // 二者守的都是「S8 存了含凭据的连接串,别泄露、别被覆盖」这一风险。
+    // 该风险随 ado_s8_data_source 与 S8DataSourceService 一并物理消失 ——
+    // S8 已不再持有任何连接串,因此没有可脱敏、也没有可覆盖的东西。
+    // 「数据源功能不存在」由 S8LegacySymbolsRemovedTests 断言。
 
     /// <summary>
     /// 兼容性:查询 DTO 仍保留 TenantId / FactoryId 字段(老前端继续发送不报错),

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

@@ -33,7 +33,7 @@ public class S8WatchRuleWriteGuardTests
         SceneCode = "S1",
         RuleType = S8TimeoutRuleEvaluator.RuleTypeCode,
         Severity = S8SeverityCode.Follow,
-        DataSourceId = 5,
+        DatasetCode = "PURCHASE_DELIVERY",
         WatchObjectType = "ORDER",
         PollIntervalSeconds = 300,
     };

+ 4 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8PermissionCatalog.cs

@@ -51,7 +51,9 @@ public static class S8PermissionCatalog
     public const string ConfigOperatorBind = "s8:config:operator:bind";
     public const string ConfigRoleWrite = "s8:config:role:write";
     public const string ConfigWatchRule = "s8:config:watch-rule";
-    public const string ConfigDataSource = "s8:config:data-source";
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1:ConfigDataSource 常量已删除(数据源功能物理消失)。
+    // 目录条目保留并标 Deprecated —— 既有 SysRoleMenu 授权行随 migration 一并清理,
+    // 但目录里留一条墓碑可以让「这个码曾经存在过、别复用」这件事有据可查。
     public const string ConfigExceptionType = "s8:config:exception-type";
     public const string ConfigNotification = "s8:config:notification";
     public const string ConfigScene = "s8:config:scene";
@@ -82,7 +84,7 @@ public static class S8PermissionCatalog
         new() { Code = "s8:config:operator:bind",   Label = "维护操作员绑定", Group = GroupConfig,     SortNo = 220 },
         new() { Code = "s8:config:role:write",      Label = "维护角色权限", Group = GroupConfig,       SortNo = 230 },
         new() { Code = "s8:config:watch-rule",      Label = "维护监控规则", Group = GroupConfig,       SortNo = 240 },
-        new() { Code = "s8:config:data-source",     Label = "维护数据源",   Group = GroupConfig,       SortNo = 250 },
+        new() { Code = "s8:config:data-source",     Label = "维护数据源",   Group = GroupConfig,       SortNo = 250, Deprecated = true },
         new() { Code = "s8:config:exception-type",  Label = "维护异常类型", Group = GroupConfig,       SortNo = 260 },
         new() { Code = "s8:config:notification",    Label = "维护通知分层", Group = GroupConfig,       SortNo = 270 },
         new() { Code = "s8:config:scene",           Label = "维护场景配置", Group = GroupConfig,       SortNo = 280 },

+ 0 - 94
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDataSourcesController.cs

@@ -1,94 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
-using Admin.NET.Plugin.AiDOP.Const.S8;
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-using Admin.NET.Plugin.AiDOP.Infrastructure;
-using Admin.NET.Plugin.AiDOP.Service.S8;
-
-namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
-
-[ApiController]
-[Route("api/aidop/s8/config/data-sources")]
-[NonUnify]
-public class AdoS8ConfigDataSourcesController : ControllerBase
-{
-    private readonly S8DataSourceService _svc;
-    private readonly S8TrustedScopeResolver _scope;
-
-    public AdoS8ConfigDataSourcesController(S8DataSourceService svc, S8TrustedScopeResolver scope)
-    {
-        _svc = svc;
-        _scope = scope;
-    }
-
-    [HttpGet]
-    [S8Permission(S8PermissionCatalog.ConfigRead)]
-    public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
-    {
-        // Compatibility-only. Security scope is resolved server-side.
-        _ = tenantId; _ = factoryId;
-        var scope = await _scope.ResolveAsync();
-        return Ok(await _svc.ListAsync(scope.TenantId, scope.FactoryId));
-    }
-
-    // ================================================================================
-    // S8-LEGACY-RUNTIME-RETIREMENT-1:POST / PUT / DELETE / POST test 已退役 → 410 Gone。
-    //
-    // 410 而非 400/403/404 是刻意选择:语义为「该能力曾经存在、现已永久退役」。
-    // 400 会暗示「改对入参还能存」,404 会暗示「换个 Id 还能存」,都与事实不符。
-    //
-    // ⚠️ catch 顺序:S8WriteRetiredException 必须排在 S8NotFoundException / S8BizException 之前,
-    //    否则会被基类分支先接住、降级成 404/400。
-    // 返回固定文案常量(不是 ex.ToString()/堆栈/类型名),避免泄漏内部实现。
-    // GET 保持不变,存量 Legacy 规则绑定的数据源行仍可读(endpoint 已脱敏)。
-    //
-    // 作用域改用 RetiredWriteScope 占位而非 await _scope.ResolveAsync():
-    // 能力既已退役,就不该为一个注定 410 的请求再去解析租户上下文。
-    // Service 写方法必须在读取该占位值或访问任何仓储前抛 S8WriteRetiredException。
-    // ================================================================================
-
-    private static readonly S8TrustedScope RetiredWriteScope = new(0, 0);
-
-    private IActionResult Retired() =>
-        StatusCode(Microsoft.AspNetCore.Http.StatusCodes.Status410Gone,
-            new { message = S8DataSourceService.RetiredMessage });
-
-    [HttpPost]
-    [S8Permission(S8PermissionCatalog.ConfigDataSource)]
-    public async Task<IActionResult> CreateAsync([FromBody] AdoS8DataSource body)
-    {
-        try { return Ok(await _svc.CreateAsync(body, RetiredWriteScope)); }
-        catch (S8WriteRetiredException) { return Retired(); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-
-    [HttpPut("{id:long}")]
-    [S8Permission(S8PermissionCatalog.ConfigDataSource)]
-    public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8DataSource body)
-    {
-        try { return Ok(await _svc.UpdateAsync(id, body, RetiredWriteScope)); }
-        catch (S8WriteRetiredException) { return Retired(); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-
-    [HttpDelete("{id:long}")]
-    [S8Permission(S8PermissionCatalog.ConfigDataSource)]
-    public async Task<IActionResult> DeleteAsync(long id)
-    {
-        try { await _svc.DeleteAsync(id, RetiredWriteScope); }
-        catch (S8WriteRetiredException) { return Retired(); }
-        catch (S8NotFoundException) { return NotFound(); }
-        return Ok();
-    }
-
-    [HttpPost("{id:long}/test")]
-    [S8Permission(S8PermissionCatalog.ConfigDataSource)]
-    public async Task<IActionResult> TestAsync(long id)
-    {
-        try { return Ok(await _svc.TestAsync(id, RetiredWriteScope)); }
-        catch (S8WriteRetiredException) { return Retired(); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-}

+ 0 - 41
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DataSource.cs

@@ -1,41 +0,0 @@
-namespace Admin.NET.Plugin.AiDOP.Entity.S8;
-
-[SugarTable("ado_s8_data_source", "S8 数据源")]
-public class AdoS8DataSource
-{
-    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
-    public long Id { get; set; }
-
-    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
-    public long TenantId { get; set; }
-
-    [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
-    public long FactoryId { get; set; }
-
-    [SugarColumn(ColumnName = "data_source_code", Length = 64)]
-    public string DataSourceCode { get; set; } = string.Empty;
-
-    [SugarColumn(ColumnName = "type", Length = 64)]
-    public string Type { get; set; } = string.Empty;
-
-    [SugarColumn(ColumnName = "endpoint", Length = 1024, IsNullable = true)]
-    public string? Endpoint { get; set; }
-
-    [SugarColumn(ColumnName = "auth_type", Length = 64, IsNullable = true)]
-    public string? AuthType { get; set; }
-
-    [SugarColumn(ColumnName = "enabled", ColumnDataType = "boolean")]
-    public bool Enabled { get; set; }
-
-    [SugarColumn(ColumnName = "last_check_at", IsNullable = true)]
-    public DateTime? LastCheckAt { get; set; }
-
-    [SugarColumn(ColumnName = "last_check_status", Length = 128, IsNullable = true)]
-    public string? LastCheckStatus { get; set; }
-
-    [SugarColumn(ColumnName = "created_at")]
-    public DateTime CreatedAt { get; set; } = DateTime.Now;
-
-    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
-    public DateTime? UpdatedAt { get; set; }
-}

+ 9 - 16
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8WatchRule.cs

@@ -23,15 +23,9 @@ public class AdoS8WatchRule
     [SugarColumn(ColumnName = "scene_code", Length = 64)]
     public string SceneCode { get; set; } = string.Empty;
 
-    [SugarColumn(ColumnName = "data_source_id", ColumnDataType = "bigint")]
-    public long DataSourceId { get; set; }
-
     [SugarColumn(ColumnName = "watch_object_type", Length = 64)]
     public string WatchObjectType { get; set; } = string.Empty;
 
-    [SugarColumn(ColumnName = "expression", Length = 2000, IsNullable = true)]
-    public string? Expression { get; set; }
-
     /// <summary>S8 业务枚举 FOLLOW(关注)/ SERIOUS(严重)。</summary>
     [SugarColumn(ColumnName = "severity", Length = 32)]
     public string Severity { get; set; } = "FOLLOW";
@@ -141,19 +135,18 @@ public class AdoS8WatchRule
     public string? RuleMechanism { get; set; }
 
     // ============================================================
-    // S8-DATASET-PROVIDER-FOUNDATION-1:取数模式扩展(仅落列,本批不切换任何生产规则)
+    // 取数:唯一路径 = dataset_code → Catalog → Provider → Canonical
+    //
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1 删除了三列:
+    //   · data_source_id  —— 物理数据源概念已不存在(S8 不再维护连接)
+    //   · expression      —— S8 不再执行任意 SQL
+    //   · data_access_mode—— 只剩一种取数方式,枚举退化为常量即无意义
+    // 保留一个恒定值的模式字段,只会让读者以为还有第二条路可选。
     // ============================================================
 
     /// <summary>
-    /// 取数模式:LEGACY_SQL(规则自带 expression+data_source_id)/ STANDARD_DATASET(引用 dataset_code)。
-    /// NULL 或空白一律按 LEGACY_SQL 处理,历史规则无需回填。
-    /// </summary>
-    [SugarColumn(ColumnName = "data_access_mode", Length = 32, IsNullable = true)]
-    public string? DataAccessMode { get; set; }
-
-    /// <summary>
-    /// 标准数据集编码。仅 data_access_mode = STANDARD_DATASET 时必填且必须已有 Provider 注册;
-    /// LEGACY_SQL 模式下应为 NULL。
+    /// 标准数据集编码。必填,且必须是 Catalog 中已定义、且已有 Provider 注册的数据集
+    /// (保存期只校验前者,启用期由 S8DatasetEnableGate 校验后者)。
     /// </summary>
     [SugarColumn(ColumnName = "dataset_code", Length = 64, IsNullable = true)]
     public string? DatasetCode { get; set; }

+ 5 - 22
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -1251,15 +1251,6 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             IsHide = true,
             Remark = "S8 任务详情"
         };
-        // S8-STANDARD-DATASET-HARD-CUTOVER-1:报警规则页已物理删除,不再需要「停用」名单。
-        // 数据源页在同批次的下一步一并物理删除,届时本集合会清空。
-        //
-        // ⚠️ 不得把 aidopS8WatchRuleConfig(监控规则)加进来 —— 那是运行时权威本身。
-        var RetiredConfigMenuNames = new HashSet<string>(StringComparer.Ordinal)
-        {
-            "aidopS8DataSourceConfig",
-        };
-
         var cfg = new (long Off, string Path, string Name, string Title, string Component)[]
         {
             (11, "/aidop/s8/config/scenes", "aidopS8ScenarioConfig", "场景基础配置", "/aidop/s8/config/S8ScenarioConfigPage"),
@@ -1267,9 +1258,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             // S8-CONFIG-AUTHORITY-FIX-1:该路由只承载操作员账号绑定;S8 鉴权走 SysMenu/SysRoleMenu,
             // 与本页无关。Name / Path / Component 保持不变(避免动态路由与既有授权失效),仅纠正 Title。
             (13, "/aidop/s8/config/roles", "aidopS8RolePermissionConfig", "操作员账号绑定", "/aidop/s8/config/S8RolePermissionConfigPage"),
-            // S8-STANDARD-DATASET-HARD-CUTOVER-1:偏移 14(报警规则配置)已随功能物理删除。
-            // 偏移号不回收、不重排——重排会让既有 SysMenu 行的 Id 与新种子对不上。
-            (15, "/aidop/s8/config/data-sources", "aidopS8DataSourceConfig", "数据源配置", "/aidop/s8/config/S8DataSourceConfigPage"),
+            // S8-STANDARD-DATASET-HARD-CUTOVER-1:偏移 14(报警规则配置)与 15(数据源配置)
+            // 已随功能物理删除。偏移号不回收、不重排——重排会让既有 SysMenu 行的 Id 与新种子对不上。
             (16, "/aidop/s8/config/watch-rules", "aidopS8WatchRuleConfig", "监视规则配置", "/aidop/s8/config/S8WatchRuleConfigPage"),
             (17, "/aidop/s8/config/exception-types", "aidopS8ExceptionTypeConfig", "异常类型配置", "/aidop/s8/config/S8ExceptionTypeConfigPage"),
             (18, "/aidop/s8/config/dashboard-cells", "aidopS8DashboardCellConfig", "大屏卡片配置", "/aidop/s8/config/S8DashboardCellConfigPage"),
@@ -1278,7 +1268,6 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         };
         foreach (var (off, path, name, title, component) in cfg)
         {
-            var retired = RetiredConfigMenuNames.Contains(name);
             yield return new SysMenu
             {
                 Id = baseId + off,
@@ -1292,14 +1281,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
                 CreateTime = ct,
                 OrderNo = 100 + (int)off,
                 IsHide = true,
-                // S8-LEGACY-RUNTIME-RETIREMENT-1:停用而非删除。
-                // Status=Disable 后动态路由不再注册该页,手输 URL 也进不去;
-                // 而条目仍在,既有 SysRoleMenu 授权不会变成悬挂引用,将来要复活也只需改回 Enable。
-                //
-                // 注意:这几页本就 IsHide=true(从不出现在侧边树,靠配置中心卡片进入),
-                // 所以「隐藏」对它们是空操作 —— 真正生效的是 Status。
-                Status = retired ? StatusEnum.Disable : StatusEnum.Enable,
-                Remark = retired ? $"S8 {title}(已退役,历史保留)" : $"S8 {title}"
+                Remark = $"S8 {title}"
             };
         }
 
@@ -1334,7 +1316,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             // 于是 118 个 S8 接口实测全部「登录即可调用」。这里补齐到与
             // S8PermissionCatalog 一一对应,Btn ID 段续用 baseId+115..123。
             (15, baseId + 16, "s8:config:watch-rule",      "维护监控规则",  240),
-            (16, baseId + 15, "s8:config:data-source",     "维护数据源",    250),
+            // S8-STANDARD-DATASET-HARD-CUTOVER-1:偏移 16(维护数据源)已随功能物理删除;
+            // 偏移号不回收、不重排,避免既有 SysMenu Btn 行的 Id 与新种子错位。
             (17, baseId + 17, "s8:config:exception-type",  "维护异常类型",  260),
             (18, baseId + 12, "s8:config:notification",    "维护通知分层",  270),
             (19, baseId + 11, "s8:config:scene",           "维护场景配置",  280),

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

@@ -1,46 +0,0 @@
-namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
-
-/// <summary>
-/// S8-DATASET-PROVIDER-FOUNDATION-1:规则取数模式。
-///
-/// 用稳定常量而非 bool,因为后续可能出现第三种取数方式(例如事件流 / 外部 API 目录),
-/// bool 会在那时被迫重构成枚举并污染已有数据。
-///
-/// 兼容语义:<c>ado_s8_watch_rule.data_access_mode</c> 为 NULL / 空白(132 条历史规则的现状)
-/// 一律解析为 <see cref="LegacySql"/>,历史规则无需回填即可继续按原路径运行。
-/// </summary>
-public static class S8DataAccessMode
-{
-    /// <summary>历史模式:规则自带 expression(SQL) + data_source_id,经 S8DataSourceRowLoader 取数。</summary>
-    public const string LegacySql = "LEGACY_SQL";
-
-    /// <summary>标准数据集模式:规则只引用 dataset_code,由已注册 Provider 返回 canonical 行。</summary>
-    public const string StandardDataset = "STANDARD_DATASET";
-
-    /// <summary>
-    /// 解析取数模式。NULL / 空白 → <see cref="LegacySql"/>(向后兼容)。
-    /// 未知非空值显式抛错:静默回落 LEGACY_SQL 会让配置错误伪装成"正常运行",
-    /// 而 LEGACY_SQL 路径要求 expression 存在,回落后的报错信息会指向错误的根因。
-    /// </summary>
-    public static string Resolve(string? rawMode)
-    {
-        if (string.IsNullOrWhiteSpace(rawMode)) return LegacySql;
-
-        var mode = rawMode.Trim();
-        if (string.Equals(mode, LegacySql, StringComparison.OrdinalIgnoreCase)) return LegacySql;
-        if (string.Equals(mode, StandardDataset, StringComparison.OrdinalIgnoreCase)) return StandardDataset;
-
-        throw new S8RuleEvaluatorException(
-            "data_access_mode_invalid",
-            $"未知的取数模式 {mode},仅支持 {LegacySql} / {StandardDataset}");
-    }
-
-    /// <summary>校验用:判断字符串是否为可接受的取数模式(含 NULL / 空白)。</summary>
-    public static bool IsAcceptable(string? rawMode)
-    {
-        if (string.IsNullOrWhiteSpace(rawMode)) return true;
-        var mode = rawMode.Trim();
-        return string.Equals(mode, LegacySql, StringComparison.OrdinalIgnoreCase)
-               || string.Equals(mode, StandardDataset, StringComparison.OrdinalIgnoreCase);
-    }
-}

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

@@ -38,7 +38,7 @@ public sealed class S8DatasetEnableCheck
 ///   9. Provider 能力是否同样满足   → dataset_capability_mismatch / dataset_missing_required_field
 ///  10. 租户 / 工厂作用域          → invalid_tenant_scope / invalid_factory_scope
 ///
-/// LEGACY_SQL 规则不经过本 Gate(其校验由既有数据源 / expression 检查承担)
+/// S8-STANDARD-DATASET-HARD-CUTOVER-1:取数路径唯一,Gate 不再有"先判模式"这一步
 /// </summary>
 public class S8DatasetEnableGate : ITransient
 {
@@ -51,17 +51,9 @@ public class S8DatasetEnableGate : ITransient
         _registry = registry;
     }
 
-    /// <summary>检查规则是否具备启用条件。LEGACY_SQL 直接通过。</summary>
+    /// <summary>检查规则是否具备启用条件。</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(

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

@@ -1,94 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-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"/>。
-///
-/// 本类**逐行保留迁移前的行为**(数据源查询条件、失败 reason 码、异常消息措辞),
-/// 132 条历史规则的运行结果因此不发生变化。
-/// </summary>
-public class S8LegacySqlDataProvider : IS8LegacySqlDataProvider, ITransient
-{
-    private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
-    private readonly S8DataSourceRowLoader _rowLoader;
-
-    public S8LegacySqlDataProvider(
-        SqlSugarRepository<AdoS8DataSource> dataSourceRep,
-        S8DataSourceRowLoader rowLoader)
-    {
-        _dataSourceRep = dataSourceRep;
-        _rowLoader = rowLoader;
-    }
-
-    /// <summary>
-    /// 按规则的 data_source_id + expression 取数。
-    /// 数据源不可用 → data_source_unavailable;SQL/API 执行失败 → query_failed。两者 reason 码与迁移前一致。
-    /// </summary>
-    public async Task<S8MonitoringDataResult> LoadAsync(
-        long tenantId,
-        long factoryId,
-        AdoS8WatchRule rule,
-        string ruleType,
-        int timeoutSeconds,
-        CancellationToken cancellationToken = default)
-    {
-        var dataSource = await _dataSourceRep.AsQueryable()
-            .Where(x => x.Id == rule.DataSourceId
-                        && x.TenantId == tenantId
-                        && x.FactoryId == factoryId
-                        && x.Enabled)
-            .FirstAsync();
-
-        if (dataSource == null
-            || string.IsNullOrWhiteSpace(dataSource.Endpoint)
-            || !S8DataSourceRowLoader.IsSupportedType(dataSource.Type))
-        {
-            throw new S8RuleEvaluatorException(
-                "data_source_unavailable",
-                $"{ruleType} 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
-        }
-
-        System.Data.DataTable table;
-        try
-        {
-            table = await _rowLoader.LoadAsync(
-                dataSource,
-                rule.Expression!,
-                _dataSourceRep.Context.CurrentConnectionConfig.DbType,
-                timeoutSeconds,
-                cancellationToken);
-        }
-        catch (Exception ex)
-        {
-            throw new S8RuleEvaluatorException(
-                "query_failed",
-                $"{ruleType} 规则 {rule.RuleCode} 取数失败:{ex.Message}",
-                ex);
-        }
-
-        return new S8MonitoringDataResult
-        {
-            RowSet = S8MonitoringRowSet.FromDataTable(table),
-            DataSourceId = dataSource.Id,
-            DataAccessMode = S8DataAccessMode.LegacySql
-        };
-    }
-}

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

@@ -4,31 +4,28 @@ using Microsoft.Extensions.Logging;
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 
 /// <summary>
-/// evaluator 的**唯一**取数入口:按规则的 data_access_mode 决定走 LEGACY_SQL 还是 STANDARD_DATASET
-/// 两条路径都产出同一种 canonical 行集合。
+/// evaluator 的**唯一**取数入口:dataset_code → Catalog → Provider Registry → Provider.LoadAsync
+/// 产出 canonical 行集合。
 ///
 /// Scheduler / Evaluator / RuleService 都不得自行 resolve Provider —— 分散 lookup 会让
 /// "哪条规则实际读了什么"变得不可追溯。
 ///
-/// <b>硬边界:STANDARD_DATASET 失败绝不回落 LEGACY_SQL。</b>
-/// 回落会让迁移失败静默隐藏:规则看起来在正常跑,实际跑的还是旧 SQL
-/// 本类中 legacy provider 只在 mode 解析为 LEGACY_SQL 时被调用一次,
-/// 标准数据集分支的任何异常路径都不触碰它(有专门测试断言调用次数为 0)
+/// <b>S8-STANDARD-DATASET-HARD-CUTOVER-1:本类不再知道 SQL / DataSourceId / 连接串的存在。</b>
+/// 此前这里有一个 mode 分支:LEGACY_SQL 走 S8LegacySqlDataProvider 执行 rule.Expression
+/// 该分支连同整条 Legacy 取数链已物理删除 —— 不是"禁止走",是"没有可走的路"。
+/// 因此也不存在"取数失败回落 Legacy"的可能:唯一路径失败就是失败,绝不静默换一条路
 /// </summary>
 public class S8MonitoringDataGateway : ITransient
 {
-    private readonly IS8LegacySqlDataProvider _legacyProvider;
     private readonly S8MonitoringDataProviderRegistry _registry;
     private readonly IS8DatasetCatalog _catalog;
     private readonly ILogger<S8MonitoringDataGateway> _logger;
 
     public S8MonitoringDataGateway(
-        IS8LegacySqlDataProvider legacyProvider,
         S8MonitoringDataProviderRegistry registry,
         IS8DatasetCatalog catalog,
         ILogger<S8MonitoringDataGateway> logger)
     {
-        _legacyProvider = legacyProvider;
         _registry = registry;
         _catalog = catalog;
         _logger = logger;
@@ -44,15 +41,10 @@ public class S8MonitoringDataGateway : ITransient
         long factoryId,
         AdoS8WatchRule rule,
         string ruleType,
-        int timeoutSeconds,
         int maxRows,
         CancellationToken cancellationToken = default)
     {
-        var mode = S8DataAccessMode.Resolve(rule.DataAccessMode);
-
-        var result = mode == S8DataAccessMode.StandardDataset
-            ? await LoadFromDatasetAsync(tenantId, factoryId, rule, ruleType, cancellationToken)
-            : await _legacyProvider.LoadAsync(tenantId, factoryId, rule, ruleType, timeoutSeconds, cancellationToken);
+        var result = await LoadFromDatasetAsync(tenantId, factoryId, rule, ruleType, cancellationToken);
 
         S8EvaluatorGuard.EnsureRowCountWithinLimit(result.RowSet.Rows.Count, maxRows, ruleType, rule.RuleCode);
         return result;
@@ -70,7 +62,7 @@ public class S8MonitoringDataGateway : ITransient
         if (string.IsNullOrWhiteSpace(rule.DatasetCode))
             throw new S8RuleEvaluatorException(
                 S8DatasetReasonCode.CodeMissing,
-                $"{ruleType} 规则 {rule.RuleCode} 声明 STANDARD_DATASET 但未提供 dataset_code");
+                $"{ruleType} 规则 {rule.RuleCode} 未提供 dataset_code");
 
         var definition = _catalog.Find(rule.DatasetCode)
             ?? throw new S8RuleEvaluatorException(
@@ -123,7 +115,6 @@ public class S8MonitoringDataGateway : ITransient
         }
         catch (Exception ex)
         {
-            // 注意:这里只包装并向上抛,**不执行 rule.Expression**。
             throw new S8RuleEvaluatorException(
                 S8DatasetReasonCode.QueryFailed,
                 $"{ruleType} 规则 {rule.RuleCode} 数据集 {definition.DatasetCode} 取数失败:{ex.Message}",
@@ -134,11 +125,6 @@ public class S8MonitoringDataGateway : ITransient
             "s8_dataset_loaded dataset={Dataset} rule={Rule} tenant={Tenant} factory={Factory} rows={Rows}",
             definition.DatasetCode, rule.RuleCode, tenantId, factoryId, rowSet.Rows.Count);
 
-        return new S8MonitoringDataResult
-        {
-            RowSet = rowSet,
-            DataSourceId = 0,
-            DataAccessMode = S8DataAccessMode.StandardDataset
-        };
+        return new S8MonitoringDataResult { RowSet = rowSet };
     }
 }

+ 5 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataResult.cs

@@ -1,19 +1,14 @@
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 
 /// <summary>
-/// 一次取数的结果。除 canonical 行外,还回传建单所需的数据源标识与实际生效的取数模式。
+/// 一次取数的结果。
+///
+/// S8-STANDARD-DATASET-HARD-CUTOVER-1:原先还带 DataSourceId 与 DataAccessMode 两个字段,
+/// 用于告诉调用方"这批行实际来自哪个物理数据源、走的哪条路"。取数路径唯一之后,
+/// 两者都成了恒定值(0 / "STANDARD_DATASET"),留着只会让调用方以为还有选择。
 /// </summary>
 public sealed class S8MonitoringDataResult
 {
     /// <summary>canonical 行集合。</summary>
     public S8MonitoringRowSet RowSet { get; init; } = S8MonitoringRowSet.Empty;
-
-    /// <summary>
-    /// 实际使用的数据源 Id。LEGACY_SQL 为解析到的 ado_s8_data_source.id;
-    /// STANDARD_DATASET 无物理数据源概念,固定 0。
-    /// </summary>
-    public long DataSourceId { get; init; }
-
-    /// <summary>本次实际生效的取数模式(LEGACY_SQL / STANDARD_DATASET)。</summary>
-    public string DataAccessMode { get; init; } = S8DataAccessMode.LegacySql;
 }

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

@@ -1,109 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
-
-/// <summary>
-/// 规则创建来源。用于区分"调用方自带 SQL"与"服务端按字典生成 SQL",
-/// 因为两者的技术债性质与安全面完全不同。
-/// </summary>
-public static class S8RuleCreationOrigin
-{
-    /// <summary>
-    /// 裸 REST 创建(<c>POST /api/aidop/s8/config/watch-rules</c>):调用方可自带任意 expression。
-    /// 这是新增 SQL 技术债的来源,也是注入面。
-    /// </summary>
-    public const string ExternalApi = "EXTERNAL_API";
-
-    /// <summary>
-    /// 配置向导生成(<c>S8ConfigDraftService.GenerateRuleAsync</c>):
-    /// SQL 由服务端按 <c>ado_s8_monitor_object</c> / <c>ado_s8_monitor_metric</c> 字典生成,
-    /// 标识符经 ValidateSqlIdentifier 白名单校验,调用方不提供 SQL 文本。
-    /// </summary>
-    public const string WizardGenerated = "WIZARD_GENERATED";
-}
-
-/// <summary>
-/// S8 规则取数模式治理。
-///
-/// <b>S8-LEGACY-RUNTIME-RETIREMENT-1(本次收口)</b>:S8 正式确立为数据中台**消费层** ——
-/// 取数走 Governed Dataset → Provider → Canonical Contract,S8 不再承担源系统接入与物理连接治理。
-/// 因此 <see cref="S8DataAccessMode.StandardDataset"/> 成为**唯一允许运行**的取数模式。
-///
-/// 与上一阶段(S8-LEGACY-SQL-RESIDUAL-CLEANUP-3,按创建来源分治)的差别:
-///   · 那一阶段只掐"调用方自带 SQL",保留向导的服务端生成 SQL,因为向导当时是在产流程;
-///   · 本阶段连向导通道一并收口 —— **没有治理 Dataset 就没有可运行 Rule**,不再用 Legacy SQL 兜底。
-///
-/// <b>刻意不做的三件事</b>(改动前请先读):
-///   1. **不改历史解析语义**:<c>data_access_mode</c> 为 NULL / 空白仍解析为 LEGACY_SQL
-///      (见 <see cref="S8DataAccessMode.Resolve"/>)。把历史行伪装成 STANDARD_DATASET
-///      会让它们绕过 Enable Gate 去引用一个根本不存在的 Provider。
-///   2. **不禁止停用与只读维护**:Legacy 规则必须还能被关停、被查看,否则出问题时只能去改库。
-///   3. **不删历史数据**:存量 Legacy 规则与其 detection / exception 全部保留,
-///      只是失去运行资格;物理清理留给后续独立批次。
-/// </summary>
-public static class S8RuleCreationPolicy
-{
-    /// <summary>拒绝新增裸 SQL 规则时使用的 reason code。</summary>
-    public const string LegacySqlCreationDisabledReason = "legacy_sql_creation_disabled";
-
-    /// <summary>Legacy 规则试图进入运行态时使用的 reason code(启用 / 恢复 / 立即执行 / 调度拾取共用)。</summary>
-    public const string LegacySqlRetiredReason = "legacy_sql_retired";
-
-    /// <summary>面向用户的统一退役文案。各入口共用,避免同一条产品裁决出现多种说法。</summary>
-    public const string LegacySqlRetiredMessage =
-        "Legacy SQL 规则已退役。S8 仅允许运行 STANDARD_DATASET 规则:"
-        + "请引用已注册的治理数据集(dataset_code)重建该规则;历史 Legacy 规则仍可查看与停用。";
-
-    /// <summary>
-    /// 该规则是否具备运行资格。<c>true</c> 仅当解析后的取数模式为 STANDARD_DATASET。
-    ///
-    /// 非法模式值(既非 LEGACY_SQL 也非 STANDARD_DATASET)同样返回 <c>false</c> 而非抛错:
-    /// 调度器不能因为一条脏数据中断整个 tick,但也绝不能放它进执行路径。
-    /// </summary>
-    public static bool IsRuntimeEligible(AdoS8WatchRule rule)
-    {
-        if (rule == null) return false;
-        if (!S8DataAccessMode.IsAcceptable(rule.DataAccessMode)) return false;
-        return S8DataAccessMode.Resolve(rule.DataAccessMode) == S8DataAccessMode.StandardDataset;
-    }
-
-    /// <summary>
-    /// 新建规则时的取数模式治理。
-    ///
-    /// 现在**任何来源**都只能创建 STANDARD_DATASET 规则。<paramref name="origin"/> 予以保留:
-    /// 它仍用于区分错误文案(裸 API 自带 SQL vs 向导生成),而删除它会破坏
-    /// <c>S8WatchRuleService.CreateAsync</c> 的既有签名契约。
-    /// </summary>
-    public static void EnsureCreationAllowed(AdoS8WatchRule rule, string? origin)
-    {
-        // STANDARD_DATASET 是唯一被接受的方向。
-        if (IsRuntimeEligible(rule)) return;
-
-        // 向导曾是唯一豁免通道(服务端按字典白名单生成 SQL)。本批一并收口,
-        // 但给它单独文案:用户在向导里看到"你自带的 SQL 被拒"会完全对不上号。
-        if (string.Equals(origin, S8RuleCreationOrigin.WizardGenerated, StringComparison.Ordinal))
-            throw new S8BizException(
-                $"[{LegacySqlRetiredReason}] 配置向导不再生成 Legacy SQL 规则。"
-                + "当前所选监控对象没有对应的治理数据集,无法创建可运行规则;"
-                + "请先在数据中台完成该对象的数据集治理与 Provider 上线。");
-
-        throw new S8BizException(
-            $"[{LegacySqlCreationDisabledReason}] 不再接受新建自带 SQL 的监控规则。" +
-            $"请改用 data_access_mode={S8DataAccessMode.StandardDataset} 并引用已注册的 dataset_code;" +
-            "历史 LEGACY_SQL 规则仍可查看与停用。");
-    }
-
-    /// <summary>
-    /// 存量规则维护时的治理。<b>只拦"进入运行态",不拦"退出运行态"。</b>
-    ///
-    /// <paramref name="enabling"/> = <c>false</c>(停用 / 暂停 / 改参数)一律放行 —— 这是硬要求:
-    /// 若关停也被拦,数据集一出问题存量规则就再也关不掉。
-    /// </summary>
-    public static void EnsureMaintenanceAllowed(AdoS8WatchRule rule, bool enabling)
-    {
-        if (!enabling) return;
-        if (IsRuntimeEligible(rule)) return;
-
-        throw new S8BizException($"[{LegacySqlRetiredReason}] {LegacySqlRetiredMessage}");
-    }
-}

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

@@ -5,55 +5,31 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 /// <summary>
 /// 规则取数配置校验(纯逻辑,不访问数据库)。
 ///
-/// 刻意分成两级,因为二者的时机与严格度不同:
+/// S8-STANDARD-DATASET-HARD-CUTOVER-1:取数路径已唯一化为
+/// Dataset → Provider → Canonical,故本类不再有"模式分支"概念。
+/// 随之删除的:<c>RequiresDataSource</c>(物理数据源已不存在)、
+/// LEGACY_SQL 分支、expression 校验、data_access_mode 合法性校验。
 ///
-/// <b>ValidateForSave(保存 / 草稿)</b>——只看结构:模式合法、编码填了且在目录里、
-/// LEGACY_SQL 有 expression。**不查 Provider**,允许"数据集已定义、Provider 未上线"时
-/// 先把规则配置好存下来。
+/// 仍然刻意分成两级,因为二者的时机与严格度不同:
+///
+/// <b>ValidateForSave(保存 / 草稿)</b>——只看结构:dataset_code 填了且在目录里。
+/// **不查 Provider**,允许"数据集已定义、Provider 未上线"时先把规则配置好存下来。
 ///
 /// <b>ValidateForEnable(启用)</b>——委托 <see cref="S8DatasetEnableGate"/> 做完整运行条件校验,
 /// 含 Provider 注册、能力与字段齐备、作用域。
 /// </summary>
 public static class S8WatchRuleDataAccessValidator
 {
-    /// <summary>
-    /// 该规则是否仍需绑定物理数据源。
-    /// LEGACY_SQL(含历史 NULL)需要;STANDARD_DATASET 由 Provider 负责取数,无物理数据源概念。
-    /// </summary>
-    public static bool RequiresDataSource(AdoS8WatchRule rule)
-    {
-        if (!S8DataAccessMode.IsAcceptable(rule.DataAccessMode)) return true;
-        return S8DataAccessMode.Resolve(rule.DataAccessMode) == S8DataAccessMode.LegacySql;
-    }
-
     /// <summary>
     /// 保存 / 草稿级校验:只校验结构与目录归属,不要求 Provider 已上线。
     /// 失败抛 <see cref="S8BizException"/>。
     /// </summary>
     public static void ValidateForSave(AdoS8WatchRule rule, IS8DatasetCatalog catalog)
     {
-        if (!S8DataAccessMode.IsAcceptable(rule.DataAccessMode))
-            throw new S8BizException(
-                $"未知的取数模式 {rule.DataAccessMode},仅支持 {S8DataAccessMode.LegacySql} / {S8DataAccessMode.StandardDataset}");
-
-        var mode = S8DataAccessMode.Resolve(rule.DataAccessMode);
-
-        if (mode == S8DataAccessMode.LegacySql)
-        {
-            if (string.IsNullOrWhiteSpace(rule.Expression))
-                throw new S8BizException("LEGACY_SQL 模式的规则必须提供 expression");
-
-            // 两种模式的字段混填会让实际生效路径不可预测。
-            if (!string.IsNullOrWhiteSpace(rule.DatasetCode))
-                throw new S8BizException("LEGACY_SQL 模式的规则不应填写 dataset_code");
-
-            return;
-        }
-
-        // STANDARD_DATASET:编码必须填写且必须是目录中已定义的数据集(拦截编码写错),
+        // 编码必须填写且必须是目录中已定义的数据集(拦截编码写错),
         // 但**不检查 Provider 是否已注册** —— 那是启用期的事。
         if (string.IsNullOrWhiteSpace(rule.DatasetCode))
-            throw new S8BizException($"[{S8DatasetReasonCode.CodeMissing}] STANDARD_DATASET 模式的规则必须提供 dataset_code");
+            throw new S8BizException($"[{S8DatasetReasonCode.CodeMissing}] 监控规则必须提供 dataset_code");
 
         if (!catalog.IsDefined(rule.DatasetCode))
             throw new S8BizException($"[{S8DatasetReasonCode.NotDefined}] 数据集 {rule.DatasetCode} 未在目录中定义");
@@ -62,17 +38,9 @@ public static class S8WatchRuleDataAccessValidator
     /// <summary>
     /// 启用级校验:完整运行条件。失败抛 <see cref="S8BizException"/>,消息带 reason code。
     ///
-    /// S8-LEGACY-RUNTIME-RETIREMENT-1:**先判取数模式是否还有运行资格,再判数据集条件。**
-    /// 顺序不可颠倒 —— <see cref="S8DatasetEnableGate"/> 对 LEGACY_SQL 是直接 Pass 的
-    /// (它只负责 Dataset 侧条件),把退役判定放到它后面等于永远判不到。
-    ///
-    /// 这里是「Create 时带 enabled=true」与「UpdateParams 由停用切启用」两条路径的**共同必经点**,
-    /// 因此退役门禁挂在此处即可覆盖二者,无需散落到各 service。
+    /// 这里是「Create 时带 enabled=true」与「UpdateParams 由停用切启用」两条路径的**共同必经点**。
     /// </summary>
     public static void ValidateForEnable(
-        AdoS8WatchRule rule, S8DatasetEnableGate gate, long tenantId, long factoryId)
-    {
-        S8RuleCreationPolicy.EnsureMaintenanceAllowed(rule, enabling: true);
+        AdoS8WatchRule rule, S8DatasetEnableGate gate, long tenantId, long factoryId) =>
         gate.EnsureCanEnable(rule, tenantId, factoryId);
-    }
 }

+ 0 - 119
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8DataSourceRowLoader.cs

@@ -1,119 +0,0 @@
-using System.Data;
-using System.Text.Json;
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-using Microsoft.Extensions.Logging;
-using SqlSugar;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
-
-/// <summary>
-/// S8 规则取数:SQL 走 SqlSugarScope;API 走 HTTP GET(endpoint=完整 URL)。
-/// </summary>
-public class S8DataSourceRowLoader : ITransient
-{
-    public const string SqlType = "SQL";
-    public const string ApiType = "API";
-
-    private readonly S8SqlSugarScopeFactory _scopeFactory;
-    private readonly ILogger<S8DataSourceRowLoader> _logger;
-
-    public S8DataSourceRowLoader(S8SqlSugarScopeFactory scopeFactory, ILogger<S8DataSourceRowLoader> logger)
-    {
-        _scopeFactory = scopeFactory;
-        _logger = logger;
-    }
-
-    public async Task<DataTable> LoadAsync(
-        AdoS8DataSource dataSource,
-        string expression,
-        SqlSugar.DbType dbType,
-        int timeoutSeconds,
-        CancellationToken cancellationToken = default)
-    {
-        var type = dataSource.Type?.Trim() ?? SqlType;
-        if (string.Equals(type, SqlType, StringComparison.OrdinalIgnoreCase))
-        {
-            using var db = _scopeFactory.CreateScope(dataSource.Endpoint!, dbType, timeoutSeconds);
-            return await db.Ado.GetDataTableAsync(expression);
-        }
-
-        if (string.Equals(type, ApiType, StringComparison.OrdinalIgnoreCase))
-            return await LoadFromApiAsync(dataSource, expression, timeoutSeconds, cancellationToken);
-
-        throw new InvalidOperationException($"不支持的数据源类型:{type}");
-    }
-
-    public static bool IsSupportedType(string? type) =>
-        string.Equals(type?.Trim(), SqlType, StringComparison.OrdinalIgnoreCase)
-        || string.Equals(type?.Trim(), ApiType, StringComparison.OrdinalIgnoreCase);
-
-    private async Task<DataTable> LoadFromApiAsync(
-        AdoS8DataSource dataSource,
-        string expression,
-        int timeoutSeconds,
-        CancellationToken cancellationToken)
-    {
-        if (string.IsNullOrWhiteSpace(dataSource.Endpoint))
-            throw new InvalidOperationException("API 数据源 endpoint 为空");
-
-        var url = dataSource.Endpoint.Trim();
-        if (!string.IsNullOrWhiteSpace(expression)
-            && !expression.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
-        {
-            if (expression.StartsWith('?'))
-                url = url.Contains('?') ? url + "&" + expression.TrimStart('?') : url + expression;
-            else if (expression.StartsWith('/'))
-                url = url.TrimEnd('/') + expression;
-        }
-
-        _logger.LogDebug("S8 API 取数 url={Url} authType={AuthType}", url, dataSource.AuthType);
-        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(Math.Max(5, timeoutSeconds)) };
-        using var resp = await http.GetAsync(url, cancellationToken);
-        resp.EnsureSuccessStatusCode();
-        var json = await resp.Content.ReadAsStringAsync(cancellationToken);
-        return JsonToDataTable(json);
-    }
-
-    /// <summary>支持 {data:{list:[...]}} / {list:[...]} / [...] 。</summary>
-    internal static DataTable JsonToDataTable(string json)
-    {
-        using var doc = JsonDocument.Parse(json);
-        JsonElement arr;
-        var root = doc.RootElement;
-        if (root.ValueKind == JsonValueKind.Array)
-            arr = root;
-        else if (root.TryGetProperty("data", out var data) && data.TryGetProperty("list", out var list) && list.ValueKind == JsonValueKind.Array)
-            arr = list;
-        else if (root.TryGetProperty("list", out var list2) && list2.ValueKind == JsonValueKind.Array)
-            arr = list2;
-        else
-            throw new InvalidOperationException("API 响应不是可识别的行数组(期望 data.list / list / [])");
-
-        var table = new DataTable();
-        foreach (var item in arr.EnumerateArray())
-        {
-            if (item.ValueKind != JsonValueKind.Object) continue;
-            foreach (var prop in item.EnumerateObject())
-            {
-                if (!table.Columns.Contains(prop.Name))
-                    table.Columns.Add(prop.Name, typeof(string));
-            }
-        }
-
-        foreach (var item in arr.EnumerateArray())
-        {
-            if (item.ValueKind != JsonValueKind.Object) continue;
-            var row = table.NewRow();
-            foreach (DataColumn col in table.Columns)
-            {
-                if (item.TryGetProperty(col.ColumnName, out var v))
-                    row[col.ColumnName] = v.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined
-                        ? DBNull.Value
-                        : v.ToString();
-            }
-            table.Rows.Add(row);
-        }
-
-        return table;
-    }
-}

+ 9 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -51,11 +51,10 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         CancellationToken cancellationToken = default)
     {
         // R5 evaluator 失败语义保护:与 TIMEOUT/SHORTAGE 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
-        var accessMode = S8DataAccessMode.Resolve(rule.DataAccessMode);
-        var expressionRequired = accessMode == S8DataAccessMode.LegacySql;
-        if ((expressionRequired && string.IsNullOrWhiteSpace(rule.Expression))
-            || string.IsNullOrWhiteSpace(rule.ParamsJson))
-            throw new S8RuleEvaluatorException("rule_not_configured", $"OUT_OF_RANGE 规则 {rule.RuleCode} 缺少 expression 或 params_json");
+        // 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!); }
@@ -64,14 +63,15 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         if (string.IsNullOrWhiteSpace(parameters.MeasuredValueField))
             throw new S8RuleEvaluatorException("params_schema_invalid", $"OUT_OF_RANGE 规则 {rule.RuleCode} params 缺少必填字段 measuredValueField");
 
-        // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
-        var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
+        // 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, timeoutSeconds, maxRows, cancellationToken);
+            tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
 
-        return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, data.DataSourceId, DateTime.Now);
+        return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, DateTime.Now);
     }
 
     /// <summary>
@@ -84,7 +84,6 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         AdoS8WatchRule rule,
         long tenantId,
         long factoryId,
-        long dataSourceId,
         DateTime detectedAt)
     {
         var hits = new List<S8RuleHit>();
@@ -158,7 +157,6 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
                 SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, measured.Value, lower, upper, deviation, direction, exceptionTypeCode),
                 DetectedAt = detectedAt,
                 Title = BuildTitle(sourceObjectType, sourceObjectId, measured.Value, lower, upper, direction),
-                DataSourceId = dataSourceId,
                 OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId),
                 ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId)
             });

+ 9 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs

@@ -44,11 +44,10 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         CancellationToken cancellationToken = default)
     {
         // R5 evaluator 失败语义保护:与 TIMEOUT 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
-        var accessMode = S8DataAccessMode.Resolve(rule.DataAccessMode);
-        var expressionRequired = accessMode == S8DataAccessMode.LegacySql;
-        if ((expressionRequired && string.IsNullOrWhiteSpace(rule.Expression))
-            || string.IsNullOrWhiteSpace(rule.ParamsJson))
-            throw new S8RuleEvaluatorException("rule_not_configured", $"SHORTAGE 规则 {rule.RuleCode} 缺少 expression 或 params_json");
+        // 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!); }
@@ -59,14 +58,15 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
             || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
             throw new S8RuleEvaluatorException("params_schema_invalid", $"SHORTAGE 规则 {rule.RuleCode} params 缺少必填字段 targetQtyField/actualQtyField/exceptionTypeCode");
 
-        // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
-        var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
+        // 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, timeoutSeconds, maxRows, cancellationToken);
+            tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
 
-        return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, data.DataSourceId, DateTime.Now);
+        return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, DateTime.Now);
     }
 
     /// <summary>
@@ -79,7 +79,6 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         AdoS8WatchRule rule,
         long tenantId,
         long factoryId,
-        long dataSourceId,
         DateTime detectedAt)
     {
         var hits = new List<S8RuleHit>();
@@ -127,7 +126,6 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
                 SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, target.Value, actual.Value, shortage, ratio, parameters),
                 DetectedAt = detectedAt,
                 Title = $"[数量不足] {sourceObjectType} {sourceObjectId} 缺口 {shortage:0.##}(目标 {target.Value:0.##} / 实际 {actual.Value:0.##})",
-                DataSourceId = dataSourceId,
                 OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId),
                 ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId)
             });

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

@@ -1,39 +0,0 @@
-using SqlSugar;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
-
-/// <summary>
-/// S8-DYNAMIC-SQLSUGAR-SCOPE-FACTORY-1:S8 SQL evaluator 独立 SqlSugarScope 构造工厂。
-/// 职责:
-///   1) 调用 S8DataSourceEndpointNormalizer 规范化 raw endpoint 并派生 EndpointHash;
-///   2) 以 ConfigId="s8-eval-{endpointHash 前 16 位}" 构造 ConnectionConfig:
-///        同 endpoint → 同 ConfigId,让 SqlSugar 内部按 ConfigId dedupe,
-///        并让底层 ADO.NET 连接池按 normalized connection string 与主库共享;
-///   3) 注入 S8EvaluatorGuard.ApplyCommandTimeout 显式 CommandTimeout:
-///        独立 scope 不继承 SqlSugarSetup.SetDbAop 的全局 30s;
-///   4) 返回 SqlSugarScope;不缓存、不持有长生命周期连接、不实现 IDisposable,
-///        调用方负责 using var scope 自动 Dispose。
-/// 不读 endpoint 原文输出日志、不在异常 message 中携带 connection string 或密码。
-/// </summary>
-public sealed class S8SqlSugarScopeFactory : ITransient
-{
-    private const int ConfigIdHashLength = 16;
-    private const string ConfigIdPrefix = "s8-eval-";
-
-    public SqlSugarScope CreateScope(string rawEndpoint, DbType dbType, int commandTimeoutSeconds)
-    {
-        var normalize = S8DataSourceEndpointNormalizer.Normalize(rawEndpoint);
-        var configId = ConfigIdPrefix + normalize.EndpointHash.Substring(0, ConfigIdHashLength);
-
-        var scope = new SqlSugarScope(new ConnectionConfig
-        {
-            ConfigId = configId,
-            DbType = dbType,
-            ConnectionString = normalize.NormalizedConnectionString,
-            InitKeyType = InitKeyType.Attribute,
-            IsAutoCloseConnection = true,
-        });
-        S8EvaluatorGuard.ApplyCommandTimeout(scope, commandTimeoutSeconds);
-        return scope;
-    }
-}

+ 9 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -48,12 +48,10 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
     {
         // R5 evaluator 失败语义保护:所有"非命中判定"路径均改为抛出 S8RuleEvaluatorException,
         // 由 SchedulerService 标记 evaluate_failed 并跳过 recovery reconcile,避免对未确认未命中的 rule 误标 recovered_at。
-        // LEGACY_SQL 仍要求 expression 存在;STANDARD_DATASET 不依赖 expression。
-        var accessMode = S8DataAccessMode.Resolve(rule.DataAccessMode);
-        var expressionRequired = accessMode == S8DataAccessMode.LegacySql;
-        if ((expressionRequired && string.IsNullOrWhiteSpace(rule.Expression))
-            || string.IsNullOrWhiteSpace(rule.ParamsJson))
-            throw new S8RuleEvaluatorException("rule_not_configured", $"TIMEOUT 规则 {rule.RuleCode} 缺少 expression 或 params_json");
+        // 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!); }
@@ -64,14 +62,15 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
             || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
             throw new S8RuleEvaluatorException("params_schema_invalid", $"TIMEOUT 规则 {rule.RuleCode} params 缺少必填字段 dueAtField/statusField/exceptionTypeCode");
 
-        // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
-        var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
+        // 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, timeoutSeconds, maxRows, cancellationToken);
+            tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
 
-        return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, data.DataSourceId, DateTime.Now);
+        return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, DateTime.Now);
     }
 
     /// <summary>
@@ -84,7 +83,6 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         AdoS8WatchRule rule,
         long tenantId,
         long factoryId,
-        long dataSourceId,
         DateTime detectedAt)
     {
         var hits = new List<S8RuleHit>();
@@ -129,7 +127,6 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
                 SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, due.Value, status, parameters),
                 DetectedAt = detectedAt,
                 Title = $"[超时] {sourceObjectType} {sourceObjectId} 已超期至 {due.Value:yyyy-MM-dd HH:mm:ss}(状态 {status})",
-                DataSourceId = dataSourceId,
                 OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId),
                 ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId)
             });

+ 26 - 104
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ConfigDraftService.cs

@@ -24,7 +24,7 @@ public class S8ConfigDraftService : ITransient
     private readonly SqlSugarRepository<AdoS8MonitorObject> _monitorObjectRep;
     private readonly SqlSugarRepository<AdoS8MonitorMetric> _monitorMetricRep;
     private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
-    private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
+    private readonly IS8DatasetCatalog _datasetCatalog;
     private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
 
     public S8ConfigDraftService(
@@ -33,7 +33,7 @@ public class S8ConfigDraftService : ITransient
         SqlSugarRepository<AdoS8MonitorObject> monitorObjectRep,
         SqlSugarRepository<AdoS8MonitorMetric> monitorMetricRep,
         SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
-        SqlSugarRepository<AdoS8DataSource> dataSourceRep,
+        IS8DatasetCatalog datasetCatalog,
         SqlSugarRepository<AdoS8SceneConfig> sceneRep)
     {
         _rep = rep;
@@ -41,7 +41,7 @@ public class S8ConfigDraftService : ITransient
         _monitorObjectRep = monitorObjectRep;
         _monitorMetricRep = monitorMetricRep;
         _exceptionTypeRep = exceptionTypeRep;
-        _dataSourceRep = dataSourceRep;
+        _datasetCatalog = datasetCatalog;
         _sceneRep = sceneRep;
     }
 
@@ -213,11 +213,9 @@ public class S8ConfigDraftService : ITransient
         {
             // S8-TENANT-FACTORY-P0-CLOSURE-1:草稿已按可信作用域绑行,生成的规则沿用同一作用域。
             //
-            // S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:显式声明来源为 WizardGenerated。
-            // 本流程的 SQL 由 BuildExpression 按 monitor_object / monitor_metric 字典生成、
-            // 标识符经白名单校验,调用方不提供 SQL 文本,因此不受"禁止新增裸 SQL 规则"约束。
-            // 待向导迁移到 STANDARD_DATASET 后,此处应改回默认(ExternalApi)以收口最后一条通道。
-            var created = await _watchRuleService.CreateAsync(payload, scope, S8RuleCreationOrigin.WizardGenerated);
+            // S8-STANDARD-DATASET-HARD-CUTOVER-1:向导不再生成 SQL,只组装 dataset_code + params_json。
+            // 因此也不再需要 origin 形参去区分"谁写的 SQL"——没有 SQL 了。
+            var created = await _watchRuleService.CreateAsync(payload, scope);
 
             // S8WatchRuleService.CreateAsync 内部用 InsertAsync 不回填 Id;按 RuleCode 反查真实 id。
             var newRuleId = created.Id;
@@ -362,12 +360,17 @@ public class S8ConfigDraftService : ITransient
                        ?? NormalizeOrNull(exceptionType.SeverityDefault)
                        ?? "FOLLOW";
 
-        // dataSource:tenant/factory 下第一个 enabled
-        var dataSource = await _dataSourceRep.AsQueryable()
-            .Where(x => x.TenantId == draft.TenantId && x.FactoryId == draft.FactoryId && x.Enabled)
-            .OrderBy(x => x.Id)
-            .FirstAsync()
-            ?? throw new S8BizException("未找到可用数据源");
+        // S8-STANDARD-DATASET-HARD-CUTOVER-1:取数来源改由用户在向导中显式选择治理数据集。
+        //
+        // 原实现取「tenant/factory 下第一个 enabled 的数据源」——那是物理连接,
+        // 且"第一个"这种隐式选择本身就不该由服务端替用户做主。
+        // 现在必须显式给出 dataset_code,且必须在 Catalog 中已定义。
+        // 刻意**不做**任何"监控对象 → 数据集"的自动推断:没有真实映射时推断出来的
+        // 就是假 Provider,规则会在启用期或运行期才炸,而不是在这里被拦下。
+        var datasetCode = NormalizeOrNull(form.DatasetCode)
+            ?? throw new S8BizException("当前无可用治理数据集:请在向导中选择该监控对象对应的数据集后再生成规则");
+        if (!_datasetCatalog.IsDefined(datasetCode))
+            throw new S8BizException($"数据集 {datasetCode} 未在目录中定义,无法生成可运行规则");
 
         // scene_config:tenant/factory + stageCode + enabled(CreateAsync 也会校验,提前查给更清晰错误)
         var scene = await _sceneRep.AsQueryable()
@@ -375,9 +378,7 @@ public class S8ConfigDraftService : ITransient
             ?? throw new S8BizException("规则所属场景不存在");
         if (!scene.Enabled) throw new S8BizException("规则所属场景未启用");
 
-        // params_json + expression
         var paramsJson = BuildParamsJson(form, labels, mechanism, metric, monitorObject, exceptionTypeCode);
-        var expression = BuildExpression(mechanism, monitorObject, metric, form);
 
         return new AdoS8WatchRule
         {
@@ -385,7 +386,7 @@ public class S8ConfigDraftService : ITransient
             FactoryId = draft.FactoryId,
             RuleCode = ruleCode,
             SceneCode = stageCode,
-            DataSourceId = dataSource.Id,
+            DatasetCode = datasetCode,
             WatchObjectType = monitorObject.ObjectType,
             RuleType = RuleTypeOf(mechanism),
             RuleMechanism = mechanism,
@@ -393,7 +394,6 @@ public class S8ConfigDraftService : ITransient
             OrderFlowCode = NormalizeOrNull(form.OrderFlowCode),
             SourceObjectType = monitorObject.ObjectType,
             Severity = severity,
-            Expression = expression,
             ParamsJson = paramsJson,
             PollIntervalSeconds = form.PollIntervalSeconds is > 0 ? form.PollIntervalSeconds.Value : 300,
             TriggerCountRequired = form.TriggerCountRequired is > 0 ? form.TriggerCountRequired.Value : 1,
@@ -464,92 +464,12 @@ public class S8ConfigDraftService : ITransient
         _ => throw new S8BizException($"不支持的报警机制:{mechanism}"),
     };
 
-    // CONFIG-WIZARD-EXPRESSION-REAL-SQL-1:基于字典 source_table + 字段映射生成真实 SELECT。
-    // 仅 DATE / VALUE_RANGE 走真实 SQL;source_table 为空时由 BuildExpression 直接以 S8BizException 拒绝(不再生成不可运行表达式)。
-    // 表名/列名走 IsSafeIdentifier 白名单校验,杜绝字典脏值导致的注入。
-    private static readonly Regex SafeIdentifierPattern =
-        new(@"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$", RegexOptions.Compiled);
-
-    private static string ValidateSqlIdentifier(string raw, string what)
-    {
-        if (string.IsNullOrWhiteSpace(raw))
-            throw new S8BizException($"字典字段缺失:{what}");
-        var trimmed = raw.Trim();
-        if (!SafeIdentifierPattern.IsMatch(trimmed))
-            throw new S8BizException($"字典字段不合法:{what}={raw}");
-        return trimmed;
-    }
-
-    // BuildExpression 在 source_table 缺失或机制为 RATIO 时直接抛 S8BizException 拒绝生成,
-    // 不再回退到 WHERE 1=0 的不可运行 SQL。两个常量保留为 string.Empty 防止外部引用编译失败。
-    private const string DatePlaceholderExpression = "";
-    private const string ValuePlaceholderExpression = "";
-
-    /// <summary>
-    /// 含逻辑删除字段的源表追加过滤,避免规则启用后误命中已软删行。
-    /// 当前仅 ic_item_stock 含 IsDeleted bit(1);其他表无 schema 元数据,不追加。
-    /// </summary>
-    private static string BuildSoftDeletePredicate(string? sourceTable)
-    {
-        if (string.IsNullOrWhiteSpace(sourceTable)) return string.Empty;
-        if (sourceTable.Equals("ic_item_stock", StringComparison.OrdinalIgnoreCase))
-            return " AND IsDeleted = 0";
-        return string.Empty;
-    }
-
-    private static string BuildExpression(string mechanism, AdoS8MonitorObject monitorObject, AdoS8MonitorMetric metric, S8WizardFormV1 form)
-    {
-        var sourceTable = NormalizeOrNull(monitorObject.SourceTable);
-        if (string.IsNullOrWhiteSpace(sourceTable))
-            throw new S8BizException("该监控对象缺少数据源配置,不能生成自动规则");
-
-        var table = ValidateSqlIdentifier(sourceTable, "source_table");
-        var idCol = ValidateSqlIdentifier(NormalizeOrNull(metric.ObjectIdField) ?? "id", "object_id_field");
-        var codeCol = ValidateSqlIdentifier(NormalizeOrNull(metric.ObjectCodeField) ?? idCol, "object_code_field");
-        var nameCol = ValidateSqlIdentifier(NormalizeOrNull(metric.ObjectNameField) ?? codeCol, "object_name_field");
-
-        if (mechanism == "DATE")
-        {
-            var dueCol = ValidateSqlIdentifier(NormalizeOrNull(metric.DueAtField) ?? "due_at", "due_at_field");
-            var statusCol = ValidateSqlIdentifier(NormalizeOrNull(metric.StatusField) ?? "status", "status_field");
-
-            var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
-            if (grace < 0) grace = 0;
-            var graceClause = grace > 0
-                ? $"DATE_SUB(NOW(), INTERVAL {grace} MINUTE)"
-                : "NOW()";
-
-            var states = ParseCsvStates(form.CompletedStates);
-            if (states.Count == 0) states = ParseCsvStates(metric.DefaultCompletedStates);
-            var stateClause = string.Empty;
-            if (states.Count > 0)
-            {
-                var quoted = string.Join(",", states.Select(s => "'" + s.Replace("'", "''") + "'"));
-                stateClause = $" AND {statusCol} NOT IN ({quoted})";
-            }
-
-            var softDeleteClause = BuildSoftDeletePredicate(table);
-            return $"SELECT {idCol} AS source_object_id, {codeCol} AS related_object_code, {nameCol} AS related_object_name, {dueCol} AS due_at, {statusCol} AS status FROM {table} WHERE {dueCol} IS NOT NULL AND {dueCol} < {graceClause}{stateClause}{softDeleteClause}";
-        }
-
-        if (mechanism == "VALUE_RANGE")
-        {
-            var mvCol = ValidateSqlIdentifier(NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value", "measured_value_field");
-
-            var lower = form.LowerBound ?? metric.DefaultLowerBound;
-            var upper = form.UpperBound ?? metric.DefaultUpperBound;
-            var preds = new List<string>();
-            if (lower.HasValue) preds.Add($"{mvCol} < {lower.Value.ToString(CultureInfo.InvariantCulture)}");
-            if (upper.HasValue) preds.Add($"{mvCol} > {upper.Value.ToString(CultureInfo.InvariantCulture)}");
-            var oorClause = preds.Count > 0 ? " AND (" + string.Join(" OR ", preds) + ")" : string.Empty;
-
-            var softDeleteClause = BuildSoftDeletePredicate(table);
-            return $"SELECT {idCol} AS source_object_id, {codeCol} AS related_object_code, {nameCol} AS related_object_name, {mvCol} AS measured_value FROM {table} WHERE {mvCol} IS NOT NULL{oorClause}{softDeleteClause}";
-        }
-
-        // 比例类规则需补充业务阈值口径后启用;上游 metric.Enabled=false 已拦截,此处兜底直接拒绝,避免落库不可运行规则。
-        throw new S8BizException("比例类规则需补充业务阈值口径后启用");
-    }
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1:此处原有整套 SQL 生成机构已删除 ——
+    //   BuildExpression / ValidateSqlIdentifier / SafeIdentifierPattern /
+    //   BuildSoftDeletePredicate / DatePlaceholderExpression / ValuePlaceholderExpression
+    // 它按 monitor_object.source_table + monitor_metric 字段映射拼 SELECT,是 S8 最后一处
+    // "自己造 SQL 去读别人的业务表"。S8 是数据中台消费层,取数只经 Provider,故整块移除。
+    // 相应地,向导现在要求显式选择 dataset_code。
 
     private static string BuildParamsJson(
         S8WizardFormV1 form,
@@ -675,6 +595,8 @@ public class S8ConfigDraftService : ITransient
     private sealed class S8WizardFormV1
     {
         public string? Mechanism { get; set; }
+        /// <summary>用户显式选择的治理数据集编码。生成规则的唯一取数配置。</summary>
+        public string? DatasetCode { get; set; }
         public string? StageCode { get; set; }
         public string? OrderFlowCode { get; set; }
         public string? ExceptionTypeCode { get; set; }

+ 0 - 91
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DataSourceService.cs

@@ -1,91 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-using Admin.NET.Plugin.AiDOP.Infrastructure;
-using System.Text.RegularExpressions;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8;
-
-/// <summary>
-/// S8 数据源配置服务。
-///
-/// <b>S8-LEGACY-RUNTIME-RETIREMENT-1:本服务已退化为只读。</b>
-///
-/// 产品裁决:S8 = 数据中台**消费层**,不是数据接入层。
-/// <code>
-///   Data Platform → Governed Dataset → S8 Provider → Canonical Contract → Watch Rule
-/// </code>
-/// 物理数据源(连接串 / 主机 / 端口 / 凭据)的治理权归数据平台,不由租户 S8 管理员维护。
-///
-/// <b>为什么不能只隐藏 UI</b>:<c>ado_s8_data_source.endpoint</c> 存的是**含凭据的真实连接串**,
-/// 而 API 仍可被直接调用。写能力留着就等于「入口看不见但仍可写库、仍可让 S8 发起对外连接」。
-/// 故在服务层(写入的唯一收敛点)统一拒绝。
-///
-/// <b>为什么连接测试也退役</b>:<c>TestAsync</c> 虽只写 <c>last_check_*</c>,但它会用库里的 endpoint
-/// **真实建立外部数据库连接** —— 那正是本批要终止的「S8 维护物理连接」能力,
-/// 比单纯的行更新更需要关掉。随之移除 <c>S8SqlSugarScopeFactory</c> 依赖:
-/// 本服务从此**在结构上**不具备发起外连的能力,而不只是"约定不用"。
-///
-/// <b>保留的部分</b>:
-///   · <see cref="ListAsync"/> —— 存量 Legacy 规则仍绑着这些行,排障与历史核对需要能看到(endpoint 仍脱敏);
-///   · 写方法签名 —— 硬约束,<c>S8TenantIsolationContractTests</c> 用反射断言带
-///     <c>S8TrustedScope</c> 的写入口存在、且无 scope 的旧重载不存在;
-///   · <c>ado_s8_data_source</c> 表 / <c>S8DataSourceRowLoader</c> / <c>S8LegacySqlDataProvider</c> ——
-///     本批只做 Runtime Retirement,物理清理留待后续独立批次。
-/// </summary>
-public class S8DataSourceService : ITransient
-{
-    private readonly SqlSugarRepository<AdoS8DataSource> _rep;
-
-    public S8DataSourceService(SqlSugarRepository<AdoS8DataSource> rep)
-    {
-        _rep = rep;
-    }
-
-    /// <summary>数据源写入口退役后的统一文案。与 Rule 侧退役文案分开:二者是两条不同的产品裁决。</summary>
-    public const string RetiredMessage =
-        "S8 已切换为数据中台 Dataset 消费模式,物理数据源由数据平台治理,"
-        + "S8 侧不再提供数据源的新增 / 修改 / 删除 / 连接测试。";
-
-    /// <summary>
-    /// 只读列出本作用域的数据源。endpoint 中的 Pwd / Password 一律脱敏后返回。
-    /// </summary>
-    public async Task<List<AdoS8DataSource>> ListAsync(long tenantId, long factoryId)
-    {
-        var rows = await _rep.AsQueryable()
-            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
-            .ToListAsync();
-        foreach (var r in rows) r.Endpoint = MaskSecret(r.Endpoint);
-        return rows;
-    }
-
-    // ================================================================================
-    // 写入口:全部退役。
-    //
-    // ⚠️ 抛出发生在**任何 DB 访问之前**:不做 LoadScopedAsync、不做重复性查询。
-    //    这既保证零 DB 触碰,也保证任意 id(含越权 id)一律 410 而非 404
-    //    ——「这个能力没了」优先于「这条记录不属于你」,
-    //    避免用越权探测反推他租户资源是否存在。
-    // ================================================================================
-
-    public Task<AdoS8DataSource> CreateAsync(AdoS8DataSource body, S8TrustedScope scope) =>
-        throw new S8WriteRetiredException(RetiredMessage);
-
-    public Task<AdoS8DataSource> UpdateAsync(long id, AdoS8DataSource body, S8TrustedScope scope) =>
-        throw new S8WriteRetiredException(RetiredMessage);
-
-    public Task DeleteAsync(long id, S8TrustedScope scope) =>
-        throw new S8WriteRetiredException(RetiredMessage);
-
-    public Task<object> TestAsync(long id, S8TrustedScope scope) =>
-        throw new S8WriteRetiredException(RetiredMessage);
-
-    // BUG-13:endpoint 中的 Pwd=xxx / Password=xxx(大小写不敏感)替换为 ******,保留其它字段。
-    private static readonly Regex SecretPattern = new(
-        @"(?i)(Pwd|Password)\s*=\s*([^;]*)",
-        RegexOptions.Compiled);
-
-    private static string? MaskSecret(string? endpoint)
-    {
-        if (string.IsNullOrWhiteSpace(endpoint)) return endpoint;
-        return SecretPattern.Replace(endpoint, m => $"{m.Groups[1].Value}=******");
-    }
-}

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

@@ -10,20 +10,17 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 public class S8WatchRuleService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8WatchRule> _rep;
-    private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
     private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
     private readonly IS8DatasetCatalog _datasetCatalog;
     private readonly S8DatasetEnableGate _datasetEnableGate;
 
     public S8WatchRuleService(
         SqlSugarRepository<AdoS8WatchRule> rep,
-        SqlSugarRepository<AdoS8DataSource> dataSourceRep,
         SqlSugarRepository<AdoS8SceneConfig> sceneRep,
         IS8DatasetCatalog datasetCatalog,
         S8DatasetEnableGate datasetEnableGate)
     {
         _rep = rep;
-        _dataSourceRep = dataSourceRep;
         _sceneRep = sceneRep;
         _datasetCatalog = datasetCatalog;
         _datasetEnableGate = datasetEnableGate;
@@ -36,17 +33,13 @@ public class S8WatchRuleService : ITransient
 
     // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
     //
-    // S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:新增 origin 参数用于治理新增 SQL 技术债。
-    // 默认值刻意取最严格的 ExternalApi(fail-closed)——将来新增调用方若忘记声明来源,
-    // 会被拦下而不是意外获得放行。
-    public async Task<AdoS8WatchRule> CreateAsync(
-        AdoS8WatchRule body,
-        S8TrustedScope scope,
-        string origin = S8RuleCreationOrigin.ExternalApi)
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1:去掉 origin 形参与 S8RuleCreationPolicy。
+    // 那套「按创建来源分治」的治理,存在意义是区分"调用方自带 SQL"与"服务端生成 SQL";
+    // 两者现在都不存在了 —— 规则唯一的取数配置是 dataset_code,由 ValidateAsync 强制。
+    public async Task<AdoS8WatchRule> CreateAsync(AdoS8WatchRule body, S8TrustedScope scope)
     {
         body.TenantId = scope.TenantId;
         body.FactoryId = scope.FactoryId;
-        S8RuleCreationPolicy.EnsureCreationAllowed(body, origin);
         await ValidateAsync(body, scope);
         // S8-STEP6E:新建一律走 canonical 词表 + params schema,杜绝「存得下但运行时永远不生效」。
         ValidateVocabularyForCreate(body);
@@ -287,11 +280,6 @@ public class S8WatchRuleService : ITransient
     public async Task<object> RunNowAsync(long id, S8TrustedScope scope)
     {
         var entity = await LoadScopedAsync(id, scope);
-        // S8-LEGACY-RUNTIME-RETIREMENT-1:本方法把 next_run_at 置为 NOW,是一条**独立的入队通道**,
-        // 不经过 ValidateForEnable,因此必须自带退役门禁。
-        // 放在 Enabled 判断之前:对一条已被 migration 停用的 Legacy 规则,
-        // "Legacy 已退役" 比 "规则未启用" 更贴近真实原因,也避免用户去启用它再来点一次。
-        S8RuleCreationPolicy.EnsureMaintenanceAllowed(entity, enabling: true);
         if (!entity.Enabled)
             throw new S8BizException("规则未启用,不能立即执行");
         var now = DateTime.Now;
@@ -338,10 +326,6 @@ public class S8WatchRuleService : ITransient
     public async Task<object> ResumeAsync(long id, S8TrustedScope scope)
     {
         var entity = await LoadScopedAsync(id, scope);
-        // S8-LEGACY-RUNTIME-RETIREMENT-1:恢复会清 paused_until 并把 next_run_at 置为 NOW,
-        // 即"让调度器下一轮拾取它"——这就是进入运行态,同样必须过退役门禁。
-        // 注意 PauseAsync 刻意**不加**该门禁:那是退出运行态,任何时候都必须能做。
-        S8RuleCreationPolicy.EnsureMaintenanceAllowed(entity, enabling: true);
         var now = DateTime.Now;
         await _rep.Context.Updateable<AdoS8WatchRule>()
             .SetColumns(x => new AdoS8WatchRule
@@ -367,24 +351,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-FOUNDATION-HARDENING-2:保存级校验(结构 + 目录归属,不要求 Provider 已上线)。
-        // data_access_mode 为 NULL / 空白(132 条历史规则的现状)解析为 LEGACY_SQL,行为不变。
+        // 保存级校验(dataset_code 结构 + 目录归属,不要求 Provider 已上线)。
         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 取数,无物理数据源概念,跳过本段校验。
-        if (S8WatchRuleDataAccessValidator.RequiresDataSource(body))
-        {
-            var dataSource = await _dataSourceRep.GetFirstAsync(
-                    x => x.Id == body.DataSourceId && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
-                ?? throw new S8BizException("关联数据源不存在");
-            if (!dataSource.Enabled) throw new S8BizException("关联数据源未启用");
-        }
-
         var scene = await _sceneRep.GetFirstAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.SceneCode == body.SceneCode)
             ?? throw new S8BizException("关联场景不存在");
         if (!scene.Enabled) throw new S8BizException("关联场景未启用");

+ 10 - 32
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -29,7 +29,6 @@ public class S8WatchSchedulerService : ITransient
     private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
     private readonly S8ShortageRuleEvaluator _shortageEvaluator;
     private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
-    private readonly S8DataSourceRowLoader _rowLoader;
     private readonly ILogger<S8WatchSchedulerService> _logger;
     private readonly SqlSugarRepository<AdoS8DetectionLog> _detectionLogRep;
     private readonly SqlSugarRepository<AdoS8RuleDetectionState> _detectionStateRep;
@@ -67,7 +66,6 @@ public class S8WatchSchedulerService : ITransient
         S8TimeoutRuleEvaluator timeoutEvaluator,
         S8ShortageRuleEvaluator shortageEvaluator,
         S8OutOfRangeRuleEvaluator outOfRangeEvaluator,
-        S8DataSourceRowLoader rowLoader,
         ILogger<S8WatchSchedulerService> logger,
         SqlSugarRepository<AdoS8DetectionLog> detectionLogRep,
         SqlSugarRepository<AdoS8RuleDetectionState> detectionStateRep)
@@ -83,7 +81,6 @@ public class S8WatchSchedulerService : ITransient
         _timeoutEvaluator = timeoutEvaluator;
         _shortageEvaluator = shortageEvaluator;
         _outOfRangeEvaluator = outOfRangeEvaluator;
-        _rowLoader = rowLoader;
         _logger = logger;
         _detectionLogRep = detectionLogRep;
         _detectionStateRep = detectionStateRep;
@@ -116,7 +113,7 @@ public class S8WatchSchedulerService : ITransient
     ///
     /// S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:原先此处还有一段旧 AlertRule 兼容主链
     /// (EvaluateDedupAsync → EvaluateHitsAsync → QueryDeviceRowsAsync → 直连 SQL),
-    /// 该链绕过 <see cref="S8DataSourceRowLoader"/> 自行开 SqlSugarScope 执行 rule.expression,
+    /// 该链自行开 SqlSugarScope 执行 rule.expression,
     /// 且只装载 rule_type 为空的未分类历史规则 + 需同场景恰好一条可运行 AlertRule。
     /// 经证实其唯一入口是 404 门禁的 debug controller,且 LoadExecutionRulesAsync 的过滤条件
     /// 在当前数据下恒返回空,已整体删除。本方法现在只保留三类正式 evaluator 路径。
@@ -750,21 +747,19 @@ public class S8WatchSchedulerService : ITransient
     {
         if (batchSize <= 0) batchSize = 16;
         var now = DateTime.Now;
-        // 取成局部变量而非在表达式树里引用静态常量:SqlSugar 对局部闭包变量的参数化最稳妥。
-        var runtimeEligibleMode = S8DataAccessMode.StandardDataset;
 
         var candidates = await _ruleRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
                         && x.FactoryId == factoryId
                         && x.Enabled
-                        // S8-LEGACY-RUNTIME-RETIREMENT-1(纵深防御第 1 层):
-                        // 只有 STANDARD_DATASET 有运行资格。等值比较天然排除 NULL / 空白
-                        // (SQL 三值逻辑:NULL = 'STANDARD_DATASET' → UNKNOWN → 不入选),
-                        // 因此 132 条 data_access_mode IS NULL 的历史规则即使被手工置 enabled=1 也拾取不到
+                        // S8-STANDARD-DATASET-HARD-CUTOVER-1:取数唯一依赖 dataset_code。
+                        // 保留这条谓词而非依赖 Enable Gate:Gate 只在"切换启用"那一刻生效,
+                        // 而 dataset_code 可能在此之后被清空(历史遗留行、直连改库)。
+                        // 一条没有数据集的规则跑起来只会每 tick 抛一次 dataset_code_missing
                         //
                         // 刻意做成 **SQL 谓词而非内存过滤**:Take(batchSize) 在过滤之前生效,
-                        // 内存过滤会让一批全是 Legacy 的候选把名额占满,导致同租户的 Standard 规则被饿死。
-                        && x.DataAccessMode == runtimeEligibleMode
+                        // 内存过滤会让无效候选占满名额,导致同租户的有效规则被饿死。
+                        && x.DatasetCode != null && x.DatasetCode != ""
                         && (x.PausedUntil == null || x.PausedUntil <= now)
                         && (x.NextRunAt == null || x.NextRunAt <= now)
                         && (x.LockUntil == null || x.LockUntil <= now))
@@ -794,10 +789,9 @@ public class S8WatchSchedulerService : ITransient
                 })
                 .Where(x => x.Id == c.Id
                             && x.Enabled
-                            // 纵深防御第 2 层:抢锁的乐观 UPDATE 复查取数模式。
-                            // 候选查询与本次 UPDATE 之间存在时间窗,期间该行可能被改成 Legacy;
-                            // 少了这一条就会给一条已无运行资格的规则发出租约。
-                            && x.DataAccessMode == runtimeEligibleMode
+                            // 抢锁的乐观 UPDATE 复查同一条件:候选查询与本次 UPDATE 之间存在时间窗,
+                            // 期间该行的 dataset_code 可能被清空。
+                            && x.DatasetCode != null && x.DatasetCode != ""
                             && (x.PausedUntil == null || x.PausedUntil <= runningAt)
                             && (x.NextRunAt == null || x.NextRunAt <= runningAt)
                             && (x.LockUntil == null || x.LockUntil <= runningAt))
@@ -845,22 +839,6 @@ public class S8WatchSchedulerService : ITransient
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_scope_mismatch", Stats = new() };
         }
 
-        // S8-LEGACY-RUNTIME-RETIREMENT-1(纵深防御第 3 层,最后一道):
-        // 即使有人绕过前两层(手工改库 + 直接构造 lease、或将来新增调用方直接调本方法),
-        // 也必须在**触碰 evaluator 与 S8LegacySqlDataProvider 之前**停下。
-        //
-        // 刻意 Success=true:这不是"执行失败",而是"这条规则已无运行资格"。
-        // 记 false 会累加 consecutive_failure_count 并写 last_error,把一次产品级退役
-        // 伪装成运行故障,进而触发失败自动暂停与告警噪音。语义对齐既有 rule_type_empty_skipped。
-        // 不建单、不发通知、不产生业务异常。
-        if (!S8RuleCreationPolicy.IsRuntimeEligible(rule))
-        {
-            _logger.LogWarning(
-                "legacy_rule_runtime_retired ruleId={RuleId} ruleCode={RuleCode} tenantId={Tenant} factoryId={Factory} runId={RunId}",
-                rule.Id, rule.RuleCode, tenantId, factoryId, lease.RunId);
-            return new S8RuleRunResult { Success = true, ErrorMessage = S8RuleCreationPolicy.LegacySqlRetiredReason, Stats = new() };
-        }
-
         var ruleType = rule.RuleType;
         if (string.IsNullOrWhiteSpace(ruleType))
         {

+ 1 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -71,7 +71,7 @@ public class Startup : AppStartup
         // S8-DATASET-FOUNDATION-HARDENING-2:S8 取数底座的接口映射显式注册
         // (与上方 IModuleRebuildLock 同一先例:本项目不依赖 ITransient 自动按接口注册)。
         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>();
+        // S8-STANDARD-DATASET-HARD-CUTOVER-1:IS8LegacySqlDataProvider 及其实现已物理删除。
 
         // S8-RULE01-PURCHASE-DELIVERY-A:接入第一个真实业务数据集 PURCHASE_DELIVERY。
         // Catalog 有定义 + Registry 有实现,Enable Gate 才判 READY:
@@ -180,7 +180,6 @@ public class Startup : AppStartup
                 // (运行时权威一直是 AdoS8WatchRule;该表零 Runtime Consumer)。
                 typeof(AdoS8SceneConfig),
                 typeof(AdoS8NotificationLayer),
-                typeof(AdoS8DataSource),
                 typeof(AdoS8WatchRule),
                 typeof(AdoS8RolePermissionConfig),
                 typeof(AdoS8NotificationLog),