Преглед изворни кода

feat(s8): STANDARD_DATASET 成为唯一可运行取数模式,Legacy SQL 规则退役

没有治理 Dataset 就没有可运行 Rule,不再用 Legacy SQL 兜底。

进入运行态的通道逐条设门(S8RuleCreationPolicy)
- 新增 IsRuntimeEligible / EnsureMaintenanceAllowed / legacy_sql_retired。
- Create:取消向导豁免。上一阶段按来源分治(掐裸 API、留向导服务端生成 SQL),
  本阶段一并收口;向导保留独立文案——在向导里报「不接受你自带的 SQL」
  会让用户完全对不上号,他并没写过 SQL。
- Enable:门挂在 ValidateForEnable,即 Create-and-enable 与 UpdateParams
  切启用两条路径的唯一交汇点。必须排在 Enable Gate 之前——Gate 对 LEGACY_SQL
  是直接 Pass 的(它只管 Dataset 侧条件),放后面等于永远判不到。
- Resume / RunNow:不经 ValidateForEnable,各自显式设门。
- Pause 刻意不设门:那是退出运行态,任何时候都必须能做,否则数据集一出问题
  规则就再也关不掉,只能去改库。

调度器纵深防御(S8WatchSchedulerService)
- 第 1 层:候选查询加 SQL 谓词 data_access_mode = 'STANDARD_DATASET'。
  三值逻辑天然排除 NULL,历史规则即使被手工置 enabled=1 也拾取不到。
  刻意用 SQL 谓词而非内存过滤:Take(batchSize) 在过滤前生效,
  内存过滤会让一批 Legacy 候选占满名额、饿死同租户的 Standard 规则。
- 第 2 层:抢锁的乐观 UPDATE 复查同一条件,堵住候选查询与 UPDATE 之间的时间窗。
- 第 3 层:RunSingleRuleAsync 在触碰 evaluator 与 S8LegacySqlDataProvider 之前
  兜底,记 legacy_rule_runtime_retired。刻意 Success=true:这是「无运行资格」
  不是「执行失败」,记 false 会累加 consecutive_failure_count 并写 last_error,
  把产品级退役伪装成运行故障。不建单、不发通知、不产生业务异常。

历史兼容语义不变
- data_access_mode 为 NULL/空白仍解析为 LEGACY_SQL。不把历史行伪装成
  STANDARD_DATASET——那会让它们绕过 Enable Gate 去引用不存在的 Provider。
- 1.0.485.sql 只置 enabled=0,不删行、不改 mode / dataset_code / expression /
  data_source_id,不动历史 detection / exception。判定用「非 STANDARD_DATASET」
  补集写法,脏值行同样停用,与 IsRuntimeEligible 对脏值返回 false 对齐。

向导前端:自动规则生成按钮停用 + 顶部提示前置,避免走完五步才吃报错。
草稿保存与 MANUAL_REPORT 路径不受影响,故不下线整个向导。

测试
- 新增 S8LegacyRuntimeRetirementTests:以 IL 字节码断言 Resume / RunNow /
  ValidateForEnable / 调度器四处调用点确实存在。探测器自身由阴性对照保证可信——
  PauseAsync 必须被判定为「未设门」,否则说明探测器坏成恒真。
- S8LegacySqlGovernanceTests 两条用例按新裁决反转(向导豁免取消、存量规则
  不得重新启用),就地反转而非删除,保留历史在 git blame 中可追。
- 补 IsRuntimeEligible 对 NULL / 空串 / 空白 / 大小写变体 / 脏值的判定,
  含「脏值必须返回 false 而非抛异常」——调度器每 tick 都调它,
  一条坏行不能让全租户监控停摆。

Rule 01(1329909460023)与诊断规则(1329909460024)均为 STANDARD_DATASET /
PURCHASE_DELIVERY / enabled=0,本批不受影响、未启用。

chore: bump version Web 2.4.377 / server 1.0.485
YY968XX пре 13 часа
родитељ
комит
d3dc2cfc87

+ 1 - 1
Web/package.json

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

+ 44 - 1
Web/src/views/aidop/s8/config/components/WatchRuleWizardDialog.vue

@@ -761,7 +761,20 @@ function validateGenerate(): boolean {
 	return true;
 }
 
+// S8-LEGACY-RUNTIME-RETIREMENT-1:向导生成的是 LEGACY_SQL 规则,已无运行资格。
+// 按钮已 disabled,这里再加一道前端守卫 —— 按钮的 disabled 只是 UI 状态,
+// 而后端 S8RuleCreationPolicy.EnsureCreationAllowed 才是真正的门(会返回 legacy_sql_retired)。
+// 保留函数本体不删:数据集治理完成后本向导要迁到 STANDARD_DATASET,届时这里是改造起点。
+const RULE_GENERATION_RETIRED_HINT =
+	'向导不再生成 Legacy SQL 规则。S8 仅运行 STANDARD_DATASET 规则,请先在数据中台完成该监控对象的数据集治理与 Provider 上线。';
+// 显式标注 boolean(而非让 TS 推成字面量 true),否则后续代码会被判定为不可达。
+const RULE_GENERATION_RETIRED: boolean = true;
+
 async function generateRuleFromDraft() {
+	if (RULE_GENERATION_RETIRED) {
+		ElMessage.warning(RULE_GENERATION_RETIRED_HINT);
+		return;
+	}
 	if (!validateGenerate()) return;
 	saving.value = true;
 	try {
@@ -813,6 +826,20 @@ function handleInlineExceptionTypeCreated(payload: { typeCode: string; typeName:
 			<el-step title="基础配置" />
 		</el-steps>
 
+		<!--
+			S8-LEGACY-RUNTIME-RETIREMENT-1:把「本向导已不能生成可运行规则」放在最前面,
+			而不是让用户填完五步再在提交时被拒。草稿仍可保存,供数据集治理完成后复用。
+		-->
+		<el-alert
+			v-if="!isManualReport"
+			type="info"
+			show-icon
+			:closable="false"
+			style="margin-bottom: 12px"
+			title="自动规则生成已停用"
+			description="S8 仅运行 STANDARD_DATASET 规则(数据取自数据中台治理数据集)。本向导生成的是 Legacy SQL 规则,已不具备运行资格。可继续保存草稿,待该监控对象完成数据集治理与 Provider 上线后再建规则。"
+		/>
+
 		<el-alert
 			v-if="dictionaryMissing && !isManualReport"
 			type="warning"
@@ -1122,7 +1149,23 @@ function handleInlineExceptionTypeCreated(payload: { typeCode: string; typeName:
 				<el-button @click="visible = false">取消</el-button>
 				<el-button :loading="savingDraft" @click="saveDraft">保存草稿</el-button>
 				<el-button v-if="step < 4" type="primary" @click="nextStep">下一步</el-button>
-				<el-button v-else type="primary" :loading="saving" :disabled="dictionaryMissing" @click="generateRuleFromDraft">生成正式规则</el-button>
+				<!--
+					S8-LEGACY-RUNTIME-RETIREMENT-1:向导的自动规则生成能力已停用。
+					该路径生成的是 LEGACY_SQL 规则(服务端按字典拼 SQL + 绑定 ado_s8_data_source),
+					而 STANDARD_DATASET 已成为唯一允许运行的取数模式,生成出来的规则永远无法启用。
+					后端 S8RuleCreationPolicy 已硬拒(这才是真正的门),此处只是把结论提前告诉用户,
+					避免走完五步表单才在最后一步吃报错。
+					草稿保存与 MANUAL_REPORT 模板路径均不受影响,故只禁用本按钮、不下线整个向导。
+				-->
+				<el-tooltip
+					v-else
+					content="向导不再生成 Legacy SQL 规则。S8 仅运行 STANDARD_DATASET 规则,需先在数据中台完成该监控对象的数据集治理与 Provider 上线。"
+					placement="top"
+				>
+					<span>
+						<el-button type="primary" disabled @click="generateRuleFromDraft">生成正式规则(已停用)</el-button>
+					</span>
+				</el-tooltip>
 			</template>
 		</template>
 	</el-dialog>

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

@@ -0,0 +1,215 @@
+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));
+    }
+}

+ 75 - 8
server/Admin.NET.Test/S8/S8LegacySqlGovernanceTests.cs

@@ -57,11 +57,18 @@ public class S8LegacySqlGovernanceTests
     }
 
     [Fact]
-    public void WizardGenerated_LegacySqlRule_StillAllowed()
+    public void WizardGenerated_LegacySqlRule_IsNowAlsoRejected()
     {
-        // 向导 SQL 由 S8ConfigDraftService 按字典白名单生成,非调用方自带;
-        // 该流程仍在生产使用,本批不阻断(见报告 KNOWN REMAINING SQL DEBT)。
-        S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.WizardGenerated);
+        // 语义变更(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]
@@ -86,16 +93,76 @@ public class S8LegacySqlGovernanceTests
     [Fact]
     public void ExistingLegacyRule_CanStillBeDisabled()
     {
-        // 治理只作用于"新建"。若关停也被拦,数据集/数据源一出问题规则就再也关不掉。
+        // 这条**必须**保持放行:若关停也被拦,数据集/数据源一出问题规则就再也关不掉,
+        // 只能去改库。退役门禁的方向是单向的 —— 只拦「进入运行态」。
         var existing = LegacyRule();
         S8RuleCreationPolicy.EnsureMaintenanceAllowed(existing, enabling: false);
     }
 
     [Fact]
-    public void ExistingLegacyRule_CanStillBeReEnabled()
+    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()
     {
-        // 历史规则重新启用不属于"新增技术债",不拦。
-        S8RuleCreationPolicy.EnsureMaintenanceAllowed(LegacyRule(), enabling: true);
+        // 调度器每个 tick 都会调它。脏数据必须返回 false 而不是抛异常中断整个 tick,
+        // 否则一条坏行就能让全租户的监控停摆(S8DataAccessMode.Resolve 对未知值是抛错的)。
+        var rule = LegacyRule();
+        rule.DataAccessMode = "NOT_A_REAL_MODE";
+        Assert.False(S8RuleCreationPolicy.IsRuntimeEligible(rule));
     }
 
     // ============================================================

+ 13 - 3
server/Admin.NET.Test/S8/S8WatchRuleDataAccessValidatorTests.cs

@@ -96,10 +96,20 @@ public class S8WatchRuleDataAccessValidatorTests
     }
 
     [Fact]
-    public void LegacyRule_PassesEnableGate_WithoutDatasetChecks()
-    {
+    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");
-        S8WatchRuleDataAccessValidator.ValidateForEnable(rule, Gate(), 1, 1);
+        var ex = Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForEnable(rule, Gate(), 1, 1));
+        Assert.Contains(S8RuleCreationPolicy.LegacySqlRetiredReason, ex.Message);
     }
 
     // ---------- Standard dataset · Save ----------

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

+ 40 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.485.sql

@@ -0,0 +1,40 @@
+-- S8-LEGACY-RUNTIME-RETIREMENT-1 · TASK D:停用全部 Legacy 取数模式的监控规则。
+--
+-- 产品裁决:STANDARD_DATASET 成为**唯一允许运行**的 Rule DataAccessMode。
+-- Legacy 规则不再具备运行资格 —— 但它们是历史数据,必须留下。
+--
+-- ┌── 本脚本只做一件事:enabled = 0 ────────────────────────────────────────────
+-- │ 明确**不做**(做了就是数据损坏,不是迁移):
+-- │   · 不 DELETE 任何规则行
+-- │   · 不改 data_access_mode(NULL 保持 NULL —— 见下方"判定口径")
+-- │   · 不填 dataset_code(没有真实 Provider 的 dataset_code 是伪造)
+-- │   · 不改 expression / data_source_id / params_json
+-- │   · 不动历史 ado_s8_detection_log / ado_s8_exception
+-- └────────────────────────────────────────────────────────────────────────────
+--
+-- 判定口径必须与代码 S8DataAccessMode.Resolve 完全一致:
+--     NULL / 空白 / 'LEGACY_SQL'(大小写不敏感)  → LEGACY_SQL → 无运行资格
+--     'STANDARD_DATASET'                        → 有运行资格
+-- 因此这里用「NOT (mode = 'STANDARD_DATASET')」的**补集**写法而非枚举 Legacy 值:
+-- 万一库里存在既非 Legacy 也非 Standard 的脏值(如 'SQL'),补集写法会连它一起停掉,
+-- 与 S8RuleCreationPolicy.IsRuntimeEligible 对脏值返回 false 的语义对齐;
+-- 若枚举 Legacy 值,脏值行会被漏掉而继续保持 enabled=1。
+--
+-- NULL 处理:`data_access_mode <> 'STANDARD_DATASET'` 对 NULL 求值为 UNKNOWN(不成立),
+-- 会漏掉全部 132 条历史行,故必须显式写 `IS NULL OR ...`。这是本脚本最容易写错的一处。
+--
+-- 与代码防御的关系(两者都要,不是二选一):
+--   本脚本 = 清理现存状态;代码三层防御 = 保证将来即使有人手工置 enabled=1 也不会执行。
+--
+-- 幂等:带 enabled = 1 前置条件,重复执行影响行数为 0。
+-- 验收:执行后 Legacy 规则总数不变,其中 enabled=1 的条数应为 0。
+
+UPDATE ado_s8_watch_rule
+   SET enabled = 0,
+       updated_at = NOW()
+ WHERE enabled = 1
+   AND (
+         data_access_mode IS NULL
+      OR TRIM(data_access_mode) = ''
+      OR UPPER(TRIM(data_access_mode)) <> 'STANDARD_DATASET'
+   );

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

@@ -23,53 +23,87 @@ public static class S8RuleCreationOrigin
 }
 
 /// <summary>
-/// S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:新增 SQL 技术债的治理策略
+/// S8 规则取数模式治理
 ///
-/// <b>为什么不是"一刀切禁止 LEGACY_SQL"</b>:取证显示配置向导
-/// (<c>wizard-drafts/{id}/generate-rule</c> → <c>S8WatchRuleService.CreateAsync</c>)
-/// 仍是在产的规则创建流程,且它生成的正是 LEGACY_SQL 规则。直接封死会阻断在用业务流程
+/// <b>S8-LEGACY-RUNTIME-RETIREMENT-1(本次收口)</b>:S8 正式确立为数据中台**消费层** ——
+/// 取数走 Governed Dataset → Provider → Canonical Contract,S8 不再承担源系统接入与物理连接治理。
+/// 因此 <see cref="S8DataAccessMode.StandardDataset"/> 成为**唯一允许运行**的取数模式
 ///
-/// 因此按来源分治:掐断"调用方自带 SQL",保留"服务端字典生成",
-/// 待向导迁移到 STANDARD_DATASET 后再收口最后一条通道。
+/// 与上一阶段(S8-LEGACY-SQL-RESIDUAL-CLEANUP-3,按创建来源分治)的差别:
+///   · 那一阶段只掐"调用方自带 SQL",保留向导的服务端生成 SQL,因为向导当时是在产流程;
+///   · 本阶段连向导通道一并收口 —— **没有治理 Dataset 就没有可运行 Rule**,不再用 Legacy SQL 兜底。
 ///
-/// <b>本策略只作用于"新建"</b>:历史规则的启停与维护不受约束,
-/// 否则数据源一出问题,存量规则将再也无法被关停。
+/// <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>
     /// 新建规则时的取数模式治理。
-    /// 未知来源按 <see cref="S8RuleCreationOrigin.ExternalApi"/> 处理(fail-closed):
-    /// 将来新增调用方若忘记声明来源,会被拦下而不是意外获得放行。
+    ///
+    /// 现在**任何来源**都只能创建 STANDARD_DATASET 规则。<paramref name="origin"/> 予以保留:
+    /// 它仍用于区分错误文案(裸 API 自带 SQL vs 向导生成),而删除它会破坏
+    /// <c>S8WatchRuleService.CreateAsync</c> 的既有签名契约。
     /// </summary>
     public static void EnsureCreationAllowed(AdoS8WatchRule rule, string? origin)
     {
-        // STANDARD_DATASET 是鼓励方向,任何来源都放行。
-        if (S8DataAccessMode.IsAcceptable(rule.DataAccessMode)
-            && S8DataAccessMode.Resolve(rule.DataAccessMode) == S8DataAccessMode.StandardDataset)
-            return;
+        // STANDARD_DATASET 是唯一被接受的方向。
+        if (IsRuntimeEligible(rule)) return;
 
+        // 向导曾是唯一豁免通道(服务端按字典白名单生成 SQL)。本批一并收口,
+        // 但给它单独文案:用户在向导里看到"你自带的 SQL 被拒"会完全对不上号。
         if (string.Equals(origin, S8RuleCreationOrigin.WizardGenerated, StringComparison.Ordinal))
-            return;
+            throw new S8BizException(
+                $"[{LegacySqlRetiredReason}] 配置向导不再生成 Legacy SQL 规则。"
+                + "当前所选监控对象没有对应的治理数据集,无法创建可运行规则;"
+                + "请先在数据中台完成该对象的数据集治理与 Provider 上线。");
 
         throw new S8BizException(
             $"[{LegacySqlCreationDisabledReason}] 不再接受新建自带 SQL 的监控规则。" +
             $"请改用 data_access_mode={S8DataAccessMode.StandardDataset} 并引用已注册的 dataset_code;" +
-            "历史 LEGACY_SQL 规则仍可正常运行与维护。");
+            "历史 LEGACY_SQL 规则仍可查看与停用。");
     }
 
     /// <summary>
-    /// 存量规则维护(启用 / 停用 / 改参数)时的治理。
-    /// 当前对两个方向都放行——治理目标是"不再新增旧债",不是"让旧债无法维护"。
-    /// 保留此入口是为了让边界显式:将来若要收紧,改这里而不是散落到各 service。
+    /// 存量规则维护时的治理。<b>只拦"进入运行态",不拦"退出运行态"。</b>
+    ///
+    /// <paramref name="enabling"/> = <c>false</c>(停用 / 暂停 / 改参数)一律放行 —— 这是硬要求:
+    /// 若关停也被拦,数据集一出问题存量规则就再也关不掉。
     /// </summary>
     public static void EnsureMaintenanceAllowed(AdoS8WatchRule rule, bool enabling)
     {
-        // 刻意为空:存量 LEGACY_SQL 规则的启停不受创建治理约束。
-        _ = rule;
-        _ = enabling;
+        if (!enabling) return;
+        if (IsRuntimeEligible(rule)) return;
+
+        throw new S8BizException($"[{LegacySqlRetiredReason}] {LegacySqlRetiredMessage}");
     }
 }

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

@@ -61,8 +61,18 @@ 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。
     /// </summary>
     public static void ValidateForEnable(
-        AdoS8WatchRule rule, S8DatasetEnableGate gate, long tenantId, long factoryId) =>
+        AdoS8WatchRule rule, S8DatasetEnableGate gate, long tenantId, long factoryId)
+    {
+        S8RuleCreationPolicy.EnsureMaintenanceAllowed(rule, enabling: true);
         gate.EnsureCanEnable(rule, tenantId, factoryId);
+    }
 }

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

@@ -287,6 +287,11 @@ 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;
@@ -333,6 +338,10 @@ 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

+ 31 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -1,6 +1,7 @@
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 using Microsoft.Extensions.Logging;
 using SqlSugar;
 using System.Globalization;
@@ -756,11 +757,21 @@ 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 也拾取不到。
+                        //
+                        // 刻意做成 **SQL 谓词而非内存过滤**:Take(batchSize) 在过滤之前生效,
+                        // 内存过滤会让一批全是 Legacy 的候选把名额占满,导致同租户的 Standard 规则被饿死。
+                        && x.DataAccessMode == runtimeEligibleMode
                         && (x.PausedUntil == null || x.PausedUntil <= now)
                         && (x.NextRunAt == null || x.NextRunAt <= now)
                         && (x.LockUntil == null || x.LockUntil <= now))
@@ -790,6 +801,10 @@ public class S8WatchSchedulerService : ITransient
                 })
                 .Where(x => x.Id == c.Id
                             && x.Enabled
+                            // 纵深防御第 2 层:抢锁的乐观 UPDATE 复查取数模式。
+                            // 候选查询与本次 UPDATE 之间存在时间窗,期间该行可能被改成 Legacy;
+                            // 少了这一条就会给一条已无运行资格的规则发出租约。
+                            && x.DataAccessMode == runtimeEligibleMode
                             && (x.PausedUntil == null || x.PausedUntil <= runningAt)
                             && (x.NextRunAt == null || x.NextRunAt <= runningAt)
                             && (x.LockUntil == null || x.LockUntil <= runningAt))
@@ -837,6 +852,22 @@ 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))
         {