Ver código fonte

refactor(s8): 退役业务侧规则创建与配置草稿

Batch 1 把规则定义搬进代码、Batch 2 建立了按租户供给运行策略的系统内部写路径。
在此之前业务侧仍留着三条能凭空造出规则的口子,本批把它们全部封死。

退役的能力:

- POST /config/watch-rules → 410 Gone。原实现由客户端传一个 AdoS8WatchRule,
  即调用方可以自定 rule_code / dataset_code / rule_type / source_object_type /
  scene_code,再配一段 params_json 决定它判什么——正是 Batch 1 要消灭的形态。
- DELETE /config/watch-rules/{id} → 410 Gone。规则定义的生命周期属于代码版本;
  删行会连带丢掉该租户已调好的参数与运行历史,而下次供给对账又会把它建回来。
  "不想让它跑"的正确表达是停用。
- POST /config/watch-rules/{id}/test → 物理移除。它只做配置字段校验、不取数不判定,
  且要求 ado_s8_scene_config 存在对应场景行——对代码定义的规则是无关约束。
  "启用后会命中什么"由 GET /{id}/preview 真实回答,能力完全覆盖。仓内无调用方。
- 配置向导后端整链删除:AdoS8ConfigDraftsController、S8ConfigDraftService、
  草稿 DTO。它的唯一出口是"生成规则",而创建规则已不存在,草稿失去全部意义;
  不为"以后可能用"保留死代码。

选 410 墓碑而非删 route:直接删会让遗留调用方拿到 405(该路径上还有 GET),
而 405 说的是"方法不对",会让人以为换个动词就能建。退役异常在任何 DB 访问之前
抛出,因此零写入,且任意 id(含越权 id)一律 410 而非 404。

连带清理:CreateAsync 唯一使用的 ValidateAsync / ValidateVocabularyForCreate /
ValidateParamsJsonShape / CanonicalRuleTypes 一并删除,词表校验职责整体上移到
S8RuleCatalog 的构造期校验(更严格:rule_type 为 ordinal 精确匹配);
S8WatchRuleService 随之不再依赖场景仓储与数据集目录。

ado_s8_config_draft 实体与表按既有口径 KEEP_TEMPORARILY(先退役 API、后物理清理),
运行代码已不再读写它,由测试断言无生产消费方。Wizard 无独立 SysMenu、无独立前端
路由,仅是规则页内的对话框,故本批无需任何 migration。

守卫测试改为最终语义:S8DatasetOnlyRuleCreationTests 的名字已误导
(守护的是"建规则时只能选治理数据集",而现在根本不能建),改写为
S8RuleCreationRetiredTests,原文件仍有效的断言逐条迁入未丢覆盖;新增源码守卫
断言只有 S8RuleProvisioningService 能插入 watch rule 行——该守卫表达的是
"用户/API 创建禁止、代码供给允许",不是"任何 Insert 都非法"。

前端仍保留"新建监控配置"与"草稿配置"按钮,Batch 4 才删除,期间会出现
按钮存在但后端已退役的开发态。这些提交尚未 push,Batch 3 与 Batch 4 连续完成后
再进入发布;不为让旧 UI 暂时可用而保留任何后端创建后门。

浏览器 HTTP 实测(410 / 路由不可达)统一推迟到 Batch 7 的本地隔离库回归:
当前 :5005 无后端,Database.json 指向共享库,启动会触发 Batch 2 的启动期供给
对共享库写入 runtime policy 行,按安全纪律不得为本批验证而启动。

本批不含:前端改动、Tenant-only 改造、Dedup 与 Exception 改动、DB migration。

chore: bump version server 1.0.491
YY968XX 3 dias atrás
pai
commit
bff3ed9748

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

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

+ 0 - 133
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DatasetOnlyRuleCreationTests.cs

@@ -1,133 +0,0 @@
-using System.Reflection;
-using Microsoft.AspNetCore.Mvc;
-using Admin.NET.Plugin.AiDOP.Controllers.S8;
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-using Admin.NET.Plugin.AiDOP.Service.S8;
-using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
-using Xunit;
-
-namespace Admin.NET.Plugin.AiDOP.Tests.S8;
-
-/// <summary>
-/// S8-STANDARD-DATASET-HARD-CUTOVER-1:规则创建只认治理数据集。
-///
-/// 本文件锁的是「创建链路上再也没有 SQL / 物理数据源的位置」,
-/// 与 <see cref="S8LegacySymbolsRemovedTests"/> 互补:那边证明类型不存在,
-/// 这边证明**残存的类型也不再提供入口**。
-/// </summary>
-public class S8DatasetOnlyRuleCreationTests
-{
-    private static readonly Assembly PluginAssembly = typeof(AdoS8WatchRule).Assembly;
-
-    // ============================================================
-    // T7 / T8:向导与数据源入口
-    // ============================================================
-
-    /// <summary>
-    /// T7:向导后端不得再持有任何生成 SQL 的能力。
-    ///
-    /// 这几个方法曾按 monitor_object.source_table + monitor_metric 字段映射拼 SELECT,
-    /// 是 S8 最后一处「自己造 SQL 去读别人的业务表」。逐名断言而非只看行为,
-    /// 是因为它们是 private —— 行为测试删掉一个分支就能变绿,名字还在就说明能力还在。
-    /// </summary>
-    [Theory]
-    [InlineData("BuildExpression")]
-    [InlineData("ValidateSqlIdentifier")]
-    [InlineData("BuildSoftDeletePredicate")]
-    public void ConfigDraftService_HasNoSqlGenerationCapability(string methodName)
-    {
-        Assert.Null(typeof(S8ConfigDraftService).GetMethod(
-            methodName, BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public));
-    }
-
-    /// <summary>向导服务不得再注入任何能提供物理数据源的依赖。</summary>
-    [Fact]
-    public void ConfigDraftService_HoldsNoDataSourceDependency()
-    {
-        var fieldTypes = typeof(S8ConfigDraftService)
-            .GetFields(BindingFlags.Instance | BindingFlags.NonPublic)
-            .Select(f => f.FieldType.FullName ?? f.FieldType.Name)
-            .ToArray();
-
-        Assert.DoesNotContain(fieldTypes, t => t.Contains("AdoS8DataSource", StringComparison.Ordinal));
-        Assert.DoesNotContain(fieldTypes, t => t.Contains("S8SqlSugarScopeFactory", StringComparison.Ordinal));
-        // 正向:必须持有数据集目录,否则无从校验用户选的 dataset_code。
-        Assert.Contains(fieldTypes, t => t.Contains("IS8DatasetCatalog", StringComparison.Ordinal));
-    }
-
-    /// <summary>T8:数据源 Controller / Service / Entity 三者都不存在。</summary>
-    [Theory]
-    [InlineData("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8DataSource")]
-    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8DataSourceService")]
-    [InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDataSourcesController")]
-    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8DataSourceRowLoader")]
-    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8SqlSugarScopeFactory")]
-    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8LegacySqlDataProvider")]
-    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8LegacySqlDataProvider")]
-    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8DataAccessMode")]
-    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationPolicy")]
-    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationOrigin")]
-    public void LegacyDataAccessType_NoLongerExists(string fullName)
-    {
-        Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false));
-    }
-
-    // ============================================================
-    // 数据集目录端点
-    // ============================================================
-
-    /// <summary>
-    /// 取代数据源端点的是一个**只读**目录。二者性质不同,不是改名:
-    /// 旧的是租户可写的物理连接配置,新的是平台级只读目录。
-    /// 因此本用例断言它一个写动作都没有。
-    /// </summary>
-    [Fact]
-    public void DatasetsController_IsReadOnly()
-    {
-        var actions = typeof(AdoS8ConfigDatasetsController)
-            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
-            .ToArray();
-
-        Assert.NotEmpty(actions);
-        foreach (var m in actions)
-        {
-            Assert.Empty(m.GetCustomAttributes<HttpPostAttribute>());
-            Assert.Empty(m.GetCustomAttributes<HttpPutAttribute>());
-            Assert.Empty(m.GetCustomAttributes<HttpDeleteAttribute>());
-            Assert.Empty(m.GetCustomAttributes<HttpPatchAttribute>());
-        }
-    }
-
-    // ============================================================
-    // T11:Rule 01 仍然合法
-    // ============================================================
-
-    /// <summary>
-    /// T11:Rule 01 的配置形态在删掉三个 Legacy 列之后仍然完整可表达 ——
-    /// 它本来就只依赖 dataset_code,这也是当初把它建成 STANDARD_DATASET 的意义。
-    /// </summary>
-    [Fact]
-    public void Rule01Shape_RemainsExpressible_AfterLegacyColumnsRemoved()
-    {
-        var rule01 = new AdoS8WatchRule
-        {
-            Id = 1329909460023L,
-            RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY",
-            TenantId = 838257186181189L,
-            FactoryId = 838257186320453L,
-            RuleType = "TIMEOUT",
-            DatasetCode = "PURCHASE_DELIVERY",
-            Enabled = false
-        };
-
-        Assert.Equal("PURCHASE_DELIVERY", rule01.DatasetCode);
-        Assert.False(rule01.Enabled);
-
-        // 保存级校验只看 dataset_code 是否在目录内 —— Rule 01 用的正是真实业务数据集定义。
-        var catalog = new S8DatasetCatalog(new IS8DatasetDefinitionSource[]
-        {
-            new Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8BusinessDatasetDefinitions()
-        });
-        S8WatchRuleDataAccessValidator.ValidateForSave(rule01, catalog);
-    }
-}

+ 386 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleCreationRetiredTests.cs

@@ -0,0 +1,386 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using System.Runtime.CompilerServices;
+using Microsoft.AspNetCore.Mvc;
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-RULE-GOVERNANCE-BATCH3:<b>业务侧根本不能创建 / 删除监控规则</b>。
+///
+/// <para>本文件取代了原 <c>S8DatasetOnlyRuleCreationTests</c>。那个名字守护的是
+/// 「创建规则时只能选治理数据集」—— 一条已经过时的口径:现在不存在"创建规则"这件事,
+/// 继续用那个名字会让读者以为 Rule Builder 还在,只是被限制了选项。</para>
+///
+/// <para>最终模型:</para>
+/// <code>
+///   业务需求 → 研发实现 S8RuleDefinition → S8RuleCatalog → Release
+///            → S8RuleProvisioningService → 每租户一条 Runtime Policy(默认停用)
+/// </code>
+/// <para>用户能做的只有:调整已发布规则的运行参数、启停、预演、立即执行、暂停恢复。</para>
+///
+/// <para>原文件中仍然有效的断言(Legacy 类型不存在 / 数据集端点只读 / Rule 01 形态可表达)
+/// 已逐条迁入本文件,未丢失覆盖。</para>
+///
+/// <para>不接 DB、不接 DI:退役守卫在**任何 DB 访问之前**抛出,因此用
+/// <see cref="RuntimeHelpers.GetUninitializedObject"/> 绕开构造函数即可驱动。
+/// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 失败 ——
+/// 这正是我们要的信号:<b>守卫必须前置</b>。</para>
+/// </summary>
+public class S8RuleCreationRetiredTests
+{
+    private static readonly Assembly PluginAssembly = typeof(AdoS8WatchRule).Assembly;
+
+    private static S8WatchRuleService Svc() =>
+        (S8WatchRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8WatchRuleService));
+
+    private static readonly S8TrustedScope Scope = new(838257186181189L, 838257186320453L);
+
+    private static AdoS8WatchRule Body() => new()
+    {
+        RuleCode = "RULE_A_USER_INVENTED",
+        SceneCode = "S1",
+        RuleType = "TIMEOUT",
+        DatasetCode = "PURCHASE_DELIVERY",
+        WatchObjectType = "ORDER",
+        PollIntervalSeconds = 300
+    };
+
+    // ───────────────────────── T1:创建能力 ─────────────────────────
+
+    [Fact]
+    public void T1_CreateAsync_IsRetired_WithGuidanceMessage()
+    {
+        var ex = Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(Body(), Scope); });
+
+        Assert.Equal(S8WriteRetiredException.WatchRuleCreateMessage, ex.Message);
+
+        // 文案必须说清"新规则从哪来",否则用户只会以为接口坏了。
+        Assert.Contains("系统版本定义", ex.Message);
+        Assert.DoesNotContain("unsupported", ex.Message, StringComparison.OrdinalIgnoreCase);
+        Assert.DoesNotContain("unknown", ex.Message, StringComparison.OrdinalIgnoreCase);
+
+        // 与"整实体更新退役"是不同的事,文案不得混用。
+        Assert.NotEqual(S8WriteRetiredException.WatchRuleUpdateMessage, ex.Message);
+    }
+
+    /// <summary>入参是否"合法"与能力是否存在无关:任何 body 都必须 410,且零 DB 访问。</summary>
+    [Fact]
+    public void T1_CreateAsync_IsRetired_RegardlessOfPayload()
+    {
+        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(new AdoS8WatchRule(), Scope); });
+
+        var wellFormed = Body();
+        wellFormed.RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY";   // 连"合法"的编码也不行
+        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(wellFormed, Scope); });
+    }
+
+    [Fact]
+    public void T1_CreateEndpoint_Returns410_AndTouchesNoScope()
+    {
+        var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "CreateAsync"));
+
+        // 墓碑而非删 route:直接删会让遗留调用方拿到 405(该路径上还有 GET),
+        // 而 405 说的是"方法不对",会让人以为换个动词就能建。
+        Assert.Single(action.GetCustomAttributes<HttpPostAttribute>());
+        Assert.NotEmpty(action.GetCustomAttributes<ObsoleteAttribute>());
+
+        // 同步返回:能力已退役,不该为构造一个用不到的 scope 去 await 组织库查询。
+        Assert.Equal(typeof(IActionResult), action.ReturnType);
+    }
+
+    // ───────────────────────── T2:删除能力 ─────────────────────────
+
+    [Fact]
+    public void T2_DeleteAsync_IsRetired_AndPointsToDisable()
+    {
+        var ex = Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(1329909460023L, Scope); });
+
+        Assert.Equal(S8WriteRetiredException.WatchRuleDeleteMessage, ex.Message);
+        Assert.Contains("停用", ex.Message);   // 必须给出正确的替代动作
+    }
+
+    [Theory]
+    [InlineData(0L)]
+    [InlineData(-1L)]
+    [InlineData(long.MaxValue)]
+    public void T2_DeleteAsync_IsRetired_RegardlessOfId(long id)
+    {
+        // 任意 id(含越权 id)一律 410 而非 404:
+        // 「这个能力没了」优先于「这条记录不属于你」,避免越权探测反推他租户数据是否存在。
+        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(id, Scope); });
+    }
+
+    [Fact]
+    public void T2_DeleteEndpoint_Returns410()
+    {
+        var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "DeleteAsync"));
+        Assert.Single(action.GetCustomAttributes<HttpDeleteAttribute>());
+        Assert.NotEmpty(action.GetCustomAttributes<ObsoleteAttribute>());
+        Assert.Equal(typeof(IActionResult), action.ReturnType);
+    }
+
+    // ───────────────────────── T3 / T4 / T14 / T15:Wizard 全链消失 ─────────────────────────
+
+    /// <summary>
+    /// 向导后端整体退役。它的唯一出口是"生成规则",而创建规则已经不存在,
+    /// 草稿也就失去了全部意义 —— 不为"以后可能用"保留死代码。
+    /// </summary>
+    [Theory]
+    [InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDraftsController")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8ConfigDraftService")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftCreateDto")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftUpdateDto")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftGenerateRuleDto")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftDetailDto")]
+    public void T3_T4_WizardBackendType_NoLongerExists(string fullName)
+    {
+        Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false));
+    }
+
+    /// <summary>T14:整个程序集里不得再有任何 wizard-drafts 路由。</summary>
+    [Fact]
+    public void T14_NoWizardDraftRouteRemains()
+    {
+        var offenders = PluginAssembly.GetTypes()
+            .Where(t => typeof(ControllerBase).IsAssignableFrom(t))
+            .SelectMany(t => t.GetCustomAttributes<RouteAttribute>().Select(r => r.Template ?? string.Empty))
+            .Where(t => t.Contains("wizard", StringComparison.OrdinalIgnoreCase)
+                        || t.Contains("draft", StringComparison.OrdinalIgnoreCase))
+            .ToArray();
+
+        Assert.Empty(offenders);
+    }
+
+    /// <summary>
+    /// T15:实体与表按既有口径 KEEP_TEMPORARILY(先退役 API、后物理清理),
+    /// 但**运行代码不得再读写它** —— 除实体自身外不应有任何类型引用它。
+    /// </summary>
+    [Fact]
+    public void T15_ConfigDraftEntity_Survives_ButNoProductionCodeConsumesIt()
+    {
+        var entity = PluginAssembly.GetType("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8ConfigDraft", throwOnError: false);
+        Assert.NotNull(entity);   // 表还在,等后续 schema cleanup
+
+        var consumers = PluginAssembly.GetTypes()
+            .Where(t => t != entity && !t.Name.Contains("Startup", StringComparison.Ordinal))
+            .Where(t => t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
+                .Any(f => f.FieldType.IsGenericType
+                          && f.FieldType.GetGenericArguments().Contains(entity)))
+            .Select(t => t.FullName)
+            .ToArray();
+
+        Assert.Empty(consumers);
+    }
+
+    // ───────────────────────── T5 / T13:其余入口 ─────────────────────────
+
+    /// <summary>T5:CreateAsync 不再是业务生产写路径 —— 名字还在,但恒抛退役异常。</summary>
+    [Fact]
+    public void T5_CreateAsync_IsNotAProductionWritePath()
+    {
+        var m = typeof(S8WatchRuleService).GetMethod("CreateAsync",
+            new[] { typeof(AdoS8WatchRule), typeof(S8TrustedScope) });
+        Assert.NotNull(m);
+
+        // 保留方法名而非删掉:断言"恒抛退役异常"比断言"名字不存在"更能防住有人换个名字复活它。
+        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(Body(), Scope); });
+
+        // 无作用域重载不得存在。
+        Assert.Null(typeof(S8WatchRuleService).GetMethod("CreateAsync", new[] { typeof(AdoS8WatchRule) }));
+    }
+
+    /// <summary>T13:TestAsync 已物理移除(能力被 GET /{id}/preview 完全覆盖)。</summary>
+    [Fact]
+    public void T13_TestEndpointAndServiceMethod_AreGone()
+    {
+        Assert.Null(typeof(S8WatchRuleService).GetMethods().FirstOrDefault(m => m.Name == "TestAsync"));
+        Assert.Empty(Actions(typeof(AdoS8ConfigWatchRulesController), "TestAsync"));
+    }
+
+    // ───────────────────────── T6:唯一合法运行时创建源 ─────────────────────────
+
+    /// <summary>
+    /// T6:<b>Provisioning 是唯一合法的 runtime create source</b>。
+    ///
+    /// <para>注意这条守卫要表达的不是"任何 Insert 都非法" —— 那会误伤系统供给路径。
+    /// 要表达的是:<c>用户/API 创建 = 禁止</c>,<c>代码供给 = 允许</c>。
+    /// 因此用源码扫描找出所有对 <c>AdoS8WatchRule</c> 的 Insert 语句,
+    /// 断言它们只出现在 <c>S8RuleProvisioningService</c> 里。</para>
+    /// </summary>
+    [Fact]
+    public void T6_OnlyProvisioningInsertsWatchRules()
+    {
+        var root = PluginSourceRoot();
+        var offenders = new List<string>();
+
+        // 只认「声明为 SqlSugarRepository<AdoS8WatchRule> 的那个字段上的插入」。
+        // 粗放地扫 AsInsertable 会误伤同一文件里对别的实体的写入 ——
+        // S8WatchSchedulerService 就同时持有 watch rule 仓储(只读)与 detection state 仓储(要写)。
+        var fieldDecl = new Regex(
+            @"SqlSugarRepository<AdoS8WatchRule>\s+(_[A-Za-z0-9_]+)", RegexOptions.Compiled);
+
+        foreach (var file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
+        {
+            var name = Path.GetFileName(file);
+            if (name == "S8RuleProvisioningService.cs") continue;      // 唯一合法系统写路径
+
+            var code = ExecutableLines(File.ReadAllText(file));
+            var fields = fieldDecl.Matches(code).Select(m => m.Groups[1].Value).Distinct().ToArray();
+
+            foreach (var line in code.Split('\n'))
+            {
+                var trimmed = line.Trim();
+
+                // 形式一:显式泛型 Insertable<AdoS8WatchRule>
+                if (trimmed.Contains("Insertable<AdoS8WatchRule>", StringComparison.Ordinal))
+                    offenders.Add($"{name}: {trimmed}");
+
+                // 形式二:在 watch rule 仓储字段上调用 AsInsertable / InsertAsync
+                foreach (var f in fields)
+                {
+                    if (trimmed.Contains($"{f}.AsInsertable", StringComparison.Ordinal)
+                        || trimmed.Contains($"{f}.InsertAsync", StringComparison.Ordinal))
+                        offenders.Add($"{name}: {trimmed}");
+                }
+            }
+        }
+
+        Assert.True(offenders.Count == 0,
+            "只有 S8RuleProvisioningService 允许创建监控规则运行策略行,以下位置出现了额外的写入:\n  "
+            + string.Join("\n  ", offenders));
+    }
+
+    /// <summary>反向对照:守卫不是"恒为空"——供给服务里确实存在那唯一一处合法插入。</summary>
+    [Fact]
+    public void T6_ProvisioningItselfDoesInsert()
+    {
+        var code = File.ReadAllText(Path.Combine(PluginSourceRoot(), "Service", "S8", "S8RuleProvisioningService.cs"));
+        Assert.Contains("_rep.AsInsertable(row)", code);
+    }
+
+    // ───────────────────────── 迁自原文件:仍然有效的断言 ─────────────────────────
+
+    /// <summary>Legacy 取数 / 数据源相关类型全部不存在(与 S8LegacySymbolsRemovedTests 互补)。</summary>
+    [Theory]
+    [InlineData("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8DataSource")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8DataSourceService")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDataSourcesController")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8DataSourceRowLoader")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8SqlSugarScopeFactory")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8LegacySqlDataProvider")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8LegacySqlDataProvider")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8DataAccessMode")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationPolicy")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationOrigin")]
+    // S8-RULE-GOVERNANCE-BATCH1:判定语义迁入代码定义后,三个 params 解析类一并删除。
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8TimeoutParams")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8ShortageParams")]
+    [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8OutOfRangeParams")]
+    public void LegacyDataAccessType_NoLongerExists(string fullName)
+    {
+        Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false));
+    }
+
+    /// <summary>
+    /// 取代数据源端点的是一个**只读**目录。二者性质不同,不是改名:
+    /// 旧的是租户可写的物理连接配置,新的是平台级只读目录。
+    /// </summary>
+    [Fact]
+    public void DatasetsController_IsReadOnly()
+    {
+        var actions = typeof(AdoS8ConfigDatasetsController)
+            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+            .ToArray();
+
+        Assert.NotEmpty(actions);
+        foreach (var m in actions)
+        {
+            Assert.Empty(m.GetCustomAttributes<HttpPostAttribute>());
+            Assert.Empty(m.GetCustomAttributes<HttpPutAttribute>());
+            Assert.Empty(m.GetCustomAttributes<HttpDeleteAttribute>());
+            Assert.Empty(m.GetCustomAttributes<HttpPatchAttribute>());
+        }
+    }
+
+    /// <summary>Rule 01 的数据集仍在目录内(供给出来的行必须能通过 Enable Gate 的目录检查)。</summary>
+    [Fact]
+    public void Rule01Dataset_RemainsDefinedInCatalog()
+    {
+        var catalog = new S8DatasetCatalog(new IS8DatasetDefinitionSource[]
+        {
+            new Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8BusinessDatasetDefinitions()
+        });
+
+        Assert.True(catalog.IsDefined("PURCHASE_DELIVERY"));
+    }
+
+    // ───────────────────────── 保留能力不得回归 ─────────────────────────
+
+    /// <summary>
+    /// T9–T11:退役不得误伤仍然合法的能力。
+    /// 少一个都意味着用户连"调整已发布规则的运行策略"都做不到了。
+    /// </summary>
+    [Theory]
+    [InlineData("UpdateParametersAsync")]
+    [InlineData("EnableAsync")]
+    [InlineData("DisableAsync")]
+    [InlineData("UpdateScheduleAsync")]
+    [InlineData("RunNowAsync")]
+    [InlineData("PauseAsync")]
+    [InlineData("ResumeAsync")]
+    [InlineData("ListAsync")]
+    public void SurvivingCapabilities_AreIntact(string method)
+    {
+        Assert.NotEmpty(typeof(S8WatchRuleService).GetMethods().Where(m => m.Name == method));
+    }
+
+    [Theory]
+    [InlineData("PreviewAsync", "GET")]
+    [InlineData("ProvisionAsync", "POST")]
+    [InlineData("EnableAsync", "POST")]
+    [InlineData("DisableAsync", "POST")]
+    [InlineData("UpdateParamsAsync", "PUT")]
+    public void SurvivingEndpoints_AreIntact(string action, string verb)
+    {
+        var m = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), action));
+        var has = verb switch
+        {
+            "GET" => m.GetCustomAttributes<HttpGetAttribute>().Any(),
+            "POST" => m.GetCustomAttributes<HttpPostAttribute>().Any(),
+            "PUT" => m.GetCustomAttributes<HttpPutAttribute>().Any(),
+            _ => false
+        };
+        Assert.True(has, $"{action} 必须仍是 {verb}");
+    }
+
+    private static IEnumerable<MethodInfo> Actions(Type controller, string name) =>
+        controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+            .Where(m => m.Name == name);
+
+    private static string PluginSourceRoot()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        return Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP");
+    }
+
+    /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
+    private static string ExecutableLines(string code) =>
+        string.Join('\n', code.Split('\n')
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal)
+                       && !t.StartsWith("*", StringComparison.Ordinal);
+            }));
+}

+ 10 - 15
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantIsolationContractTests.cs

@@ -79,7 +79,9 @@ public class S8TenantIsolationContractTests
     [InlineData(typeof(S8RoleConfigService), typeof(AdoS8RolePermissionConfig))]
     [InlineData(typeof(S8DashboardCellConfigService), typeof(AdoS8DashboardCellConfig))]
     [InlineData(typeof(S8ExceptionTypeService), typeof(AdoS8ExceptionType))]
-    [InlineData(typeof(S8WatchRuleService), typeof(AdoS8WatchRule))]
+    // S8-RULE-GOVERNANCE-BATCH3:S8WatchRuleService 已移出本用例 ——
+    // 它的 Create / Update / Delete 三个业务写入口全部退役,不再存在"带作用域的写入口"这回事。
+    // 取而代之的断言见 S8RuleCreationRetiredTests:业务根本不能创建 / 删除规则。
     public void ConfigWrites_RequireTrustedScope(Type serviceType, Type entityType)
     {
         // CreateAsync 用「前两个形参匹配」而非精确签名匹配。
@@ -120,26 +122,19 @@ public class S8TenantIsolationContractTests
     [InlineData("RunNowAsync")]
     [InlineData("PauseAsync")]
     [InlineData("ResumeAsync")]
-    [InlineData("TestAsync")]
+    // S8-RULE-GOVERNANCE-BATCH3:TestAsync 已物理移除(能力被 GET /{id}/preview 完全覆盖)。
+    [InlineData("EnableAsync")]
+    [InlineData("DisableAsync")]
     public void WatchRuleRuntimeActions_RequireTrustedScope(string method)
     {
         Assert.NotNull(typeof(S8WatchRuleService).GetMethod(method, [typeof(long), typeof(S8TrustedScope)]));
         Assert.Null(typeof(S8WatchRuleService).GetMethod(method, [typeof(long)]));
     }
 
-    /// <summary>草稿全部按 Id 的入口都必须带可信作用域。</summary>
-    [Fact]
-    public void ConfigDraft_ByIdEntryPoints_RequireTrustedScope()
-    {
-        var t = typeof(S8ConfigDraftService);
-        Assert.NotNull(t.GetMethod("GetAsync", [typeof(long), typeof(S8TrustedScope)]));
-        Assert.NotNull(t.GetMethod("DeleteAsync", [typeof(long), typeof(S8TrustedScope)]));
-        Assert.NotNull(t.GetMethod("CreateAsync", [typeof(AdoS8ConfigDraftCreateDto), typeof(S8TrustedScope)]));
-        Assert.NotNull(t.GetMethod("UpdateAsync", [typeof(long), typeof(AdoS8ConfigDraftUpdateDto), typeof(S8TrustedScope)]));
-        Assert.NotNull(t.GetMethod("GenerateRuleAsync", [typeof(long), typeof(AdoS8ConfigDraftGenerateRuleDto), typeof(S8TrustedScope)]));
-        Assert.Null(t.GetMethod("GetAsync", [typeof(long)]));
-        Assert.Null(t.GetMethod("DeleteAsync", [typeof(long)]));
-    }
+    // S8-RULE-GOVERNANCE-BATCH3:原 ConfigDraft_ByIdEntryPoints_RequireTrustedScope 已删除 ——
+    // S8ConfigDraftService / AdoS8ConfigDraftsController / 草稿 DTO 已整体退役。
+    // 「草稿的入口要不要带作用域」这个问题随入口一起消失了;
+    // 「草稿链路不存在」由 S8RuleCreationRetiredTests 断言。
 
     /// <summary>
     /// 异常时间线 / 决策 / 证据三张子表无自有 tenant_id / factory_id 列,

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

@@ -88,71 +88,18 @@ public class S8WatchRuleWriteGuardTests
         Assert.NotNull(t.GetMethod("RunNowAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
     }
 
-    // ---------------- P1:新建 canonical 词表 ----------------
-
-    private static void Validate(AdoS8WatchRule body)
-    {
-        var m = typeof(S8WatchRuleService).GetMethod(
-            "ValidateVocabularyForCreate", BindingFlags.NonPublic | BindingFlags.Static)!;
-        try { m.Invoke(null, new object[] { body }); }
-        catch (TargetInvocationException tie) { throw tie.InnerException!; }
-    }
-
-    [Fact]
-    public void Create_AcceptsCanonicalVocabulary()
-    {
-        Validate(Body());   // 反向对照:合法值必须通过,否则「全拒」可能是误伤
-    }
-
-    [Theory]
-    [InlineData("timeout")]          // 大小写:调度器 switch 是 ordinal 敏感的
-    [InlineData("NOT_A_TYPE")]
-    [InlineData("")]
-    [InlineData(null)]
-    public void Create_RejectsNonCanonicalRuleType(string? ruleType)
-    {
-        var b = Body(); b.RuleType = ruleType;
-        Assert.Throws<S8BizException>(() => Validate(b));
-    }
-
-    [Theory]
-    [InlineData("S2S6_PRODUCTION")]  // legacy 复合场景
-    [InlineData("S8_DEMO_DEFAULT")]
-    [InlineData("NOT_A_SCENE")]
-    public void Create_RejectsNonCanonicalScene(string scene)
-    {
-        var b = Body(); b.SceneCode = scene;
-        Assert.Throws<S8BizException>(() => Validate(b));
-    }
-
-    /// <summary>
-    /// 关键回归:severity 必须在 Normalize **之前**校验。
-    /// HIGH 正是 DB 中 id=187 那一行的实际值——它当年就是被 Normalize 静默吞掉才存进来的。
-    /// </summary>
-    [Theory]
-    [InlineData("HIGH")]
-    [InlineData("LOW")]
-    [InlineData("MEDIUM")]
-    [InlineData("CRITICAL")]
-    [InlineData("bogus")]
-    public void Create_RejectsLegacyAndInvalidSeverity_BeforeNormalize(string severity)
-    {
-        var b = Body(); b.Severity = severity;
-        var ex = Assert.Throws<S8BizException>(() => Validate(b));
-        Assert.Contains(severity, ex.Message);
-        // 若校验被放到 Normalize 之后,HIGH 会被静默降级成 FOLLOW 而不是抛错。
-        Assert.NotEqual(S8SeverityCode.Normalize(severity), severity);
-    }
-
-    /// <summary>canonical rule_type 集合必须与真实 evaluator 常量同源,不得手抄字面量。</summary>
-    [Fact]
-    public void CanonicalRuleTypes_MatchEvaluatorRegistry()
-    {
-        var f = typeof(S8WatchRuleService).GetField(
-            "CanonicalRuleTypes", BindingFlags.NonPublic | BindingFlags.Static)!;
-        var actual = (string[])f.GetValue(null)!;
-        Assert.Equal(
-            new[] { S8TimeoutRuleEvaluator.RuleTypeCode, S8ShortageRuleEvaluator.RuleTypeCode, S8OutOfRangeRuleEvaluator.RuleTypeCode },
-            actual);
-    }
+    // ================================================================================
+    // S8-RULE-GOVERNANCE-BATCH3:原「P1:新建 canonical 词表」整段已删除。
+    //
+    // 那一段守护的是 ValidateVocabularyForCreate —— 即"调用方提交的 rule_type / scene_code /
+    // severity 是否在词表内"。业务侧建规则能力已退役,该私有方法随之删除,
+    // 词表校验的职责整体上移到 S8RuleCatalog 的构造期校验(且更严格:rule_type 改为 ordinal
+    // 精确匹配,"timeout" 这类大小写错误在那里会直接让服务起不来,而不是存下来再运行期报错)。
+    //
+    // 对应覆盖去向:
+    //   · 词表 / 类型合法性        → S8RuleDefinitionContractTests.T2_*
+    //   · severity 不得静默降级     → S8RuleParameterContractTests.T6_NonWhitelistedSeverity_IsRejected
+    //                              + S8TimeoutEvaluatorTests.RuntimeParameters_InvalidSeverity_*
+    //   · 业务不能建 / 删规则       → S8RuleCreationRetiredTests
+    // ================================================================================
 }

+ 0 - 81
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDraftsController.cs

@@ -1,81 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
-using Admin.NET.Plugin.AiDOP.Const.S8;
-using Admin.NET.Plugin.AiDOP.Dto.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/wizard-drafts")]
-[NonUnify]
-public class AdoS8ConfigDraftsController : ControllerBase
-{
-    private readonly S8ConfigDraftService _svc;
-    private readonly S8TrustedScopeResolver _scope;
-
-    public AdoS8ConfigDraftsController(S8ConfigDraftService svc, S8TrustedScopeResolver scope)
-    {
-        _svc = svc;
-        _scope = scope;
-    }
-
-    [HttpGet]
-    [S8Permission(S8PermissionCatalog.ConfigRead)]
-    public async Task<IActionResult> ListAsync([FromQuery] AdoS8ConfigDraftListQueryDto query)
-    {
-        var scope = await _scope.ResolveAsync();
-        query.TenantId = scope.TenantId;
-        query.FactoryId = scope.FactoryId;
-        return Ok(await _svc.ListAsync(query));
-    }
-
-    [HttpGet("{id:long}")]
-    [S8Permission(S8PermissionCatalog.ConfigRead)]
-    public async Task<IActionResult> GetAsync(long id)
-    {
-        try { return Ok(await _svc.GetAsync(id, await _scope.ResolveAsync())); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-
-    [HttpPost]
-    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> CreateAsync([FromBody] AdoS8ConfigDraftCreateDto body)
-    {
-        try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-
-    [HttpPut("{id:long}")]
-    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8ConfigDraftUpdateDto body)
-    {
-        try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-
-    [HttpDelete("{id:long}")]
-    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> DeleteAsync(long id)
-    {
-        try
-        {
-            await _svc.DeleteAsync(id, await _scope.ResolveAsync());
-            return Ok(new { id, deleted = true });
-        }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-
-    [HttpPost("{id:long}/generate-rule")]
-    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> GenerateRuleAsync(long id, [FromBody] AdoS8ConfigDraftGenerateRuleDto body)
-    {
-        try { return Ok(await _svc.GenerateRuleAsync(id, body, await _scope.ResolveAsync())); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-}

+ 36 - 30
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs

@@ -40,19 +40,29 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         return Ok(await _svc.ListAsync(scope.TenantId, scope.FactoryId));
     }
 
-    /// <summary>
-    /// Legacy endpoint. Rule configuration UI must use PUT /{id}/params.
-    /// 保留兼容历史脚本/集成调用,但响应 header 与日志会标记为 deprecated。
-    /// </summary>
-    [Obsolete("Legacy full-create endpoint. Use PUT /api/aidop/s8/config/watch-rules/{id}/params for safe params editing.")]
+    // ================================================================================
+    // S8-RULE-GOVERNANCE-BATCH3:业务侧建规则入口已退役 → 410 Gone。
+    //
+    // 选 410 墓碑而非直接删掉 route:直接删会让遗留调用方拿到 405(该路径上还有 GET),
+    // 而 405 说的是"方法不对",会让人以为换个动词就能建。410 才准确表达
+    // 「这个能力曾经存在、现已永久退役」,并能把替代路径写进文案。
+    //
+    // ⚠️ 不再调用 _scope.ResolveAsync():能力已退役,不该为构造一个用不到的 scope 去读组织库。
+    //    这保证 DB access = 0,并让工厂组织 0 个/多个的租户也稳定得到 410 而非作用域错误。
+    // ⚠️ catch 顺序:S8WriteRetiredException 必须排在 S8BizException 之前,否则被基类吞成 400。
+    // ================================================================================
+    [Obsolete("Retired. Rules are defined in code (S8RuleCatalog) and provisioned per tenant.")]
     [HttpPost]
     [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> CreateAsync([FromBody] AdoS8WatchRule body)
+    public IActionResult CreateAsync([FromBody] AdoS8WatchRule body)
     {
         MarkLegacyDeprecated("POST /api/aidop/s8/config/watch-rules");
-        try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+        _ = body;
+        try { _ = _svc.CreateAsync(body, RetiredWriteScope); return Ok(); }
+        catch (S8WriteRetiredException ex)
+        {
+            return StatusCode(Microsoft.AspNetCore.Http.StatusCodes.Status410Gone, new { message = ex.Message });
+        }
     }
 
     /// <summary>
@@ -97,34 +107,30 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     private static readonly S8TrustedScope RetiredWriteScope = new(0, 0);
 
     /// <summary>
-    /// Legacy endpoint. Rule configuration UI must use PUT /{id}/params.
-    /// 保留兼容历史脚本/集成调用,但响应 header 与日志会标记为 deprecated。
+    /// S8-RULE-GOVERNANCE-BATCH3:业务侧删规则入口已退役 → 410 Gone。
+    /// 规则由系统版本定义;"不想让它跑"的正确表达是 <c>/disable</c>,不是删除。
+    /// 与 <see cref="UpdateAsync"/> / <see cref="CreateAsync"/> 同一墓碑形态。
     /// </summary>
-    [Obsolete("Legacy delete endpoint. Use PUT /api/aidop/s8/config/watch-rules/{id}/params for safe params editing.")]
+    [Obsolete("Retired. Disable the rule instead; rule lifecycle belongs to the code release.")]
     [HttpDelete("{id:long}")]
     [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> DeleteAsync(long id)
+    public IActionResult DeleteAsync(long id)
     {
         MarkLegacyDeprecated($"DELETE /api/aidop/s8/config/watch-rules/{id}", id);
-        try { await _svc.DeleteAsync(id, await _scope.ResolveAsync()); }
-        catch (S8NotFoundException) { return NotFound(); }
-        return Ok();
+        try { _ = _svc.DeleteAsync(id, RetiredWriteScope); return Ok(); }
+        catch (S8WriteRetiredException ex)
+        {
+            return StatusCode(Microsoft.AspNetCore.Http.StatusCodes.Status410Gone, new { message = ex.Message });
+        }
     }
 
-    /// <summary>
-    /// Legacy endpoint. Rule configuration UI must use PUT /{id}/params.
-    /// 保留兼容历史脚本/集成调用,但响应 header 与日志会标记为 deprecated。
-    /// </summary>
-    [Obsolete("Legacy test endpoint. Use PUT /api/aidop/s8/config/watch-rules/{id}/params for safe params editing.")]
-    [HttpPost("{id:long}/test")]
-    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> TestAsync(long id)
-    {
-        MarkLegacyDeprecated($"POST /api/aidop/s8/config/watch-rules/{id}/test", id);
-        try { return Ok(await _svc.TestAsync(id, await _scope.ResolveAsync())); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
+    // S8-RULE-GOVERNANCE-BATCH3:POST /{id}/test 已物理移除。
+    // 它做的只是 LoadScoped + 配置字段校验(rule_code/scene 非空、编码唯一、dataset 在目录内、
+    // 关联场景存在且启用、poll>0),**不取数、不判定**,返回一句"规则基础校验通过"。
+    // 这些前提现在要么由代码定义保证、要么已不适用(场景行对代码定义的规则是无关约束)。
+    // 「这条规则启用后会命中什么」由 GET /{id}/preview 真实回答,能力完全覆盖且更强。
+    // 仓内无任何调用方:前端 s8ConfigApi.watchRules 无 test,通用 CRUD 页的 test 只用于
+    // 场景/大屏卡片/角色三个 endpoint,不指向 watch-rules。
 
     /// <summary>
     /// S8-RULE01-INTEGRATION-PREVIEW-1:只读预演 —— 「这条规则现在启用会命中什么」。

+ 0 - 86
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8ConfigDraftDtos.cs

@@ -1,86 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-
-namespace Admin.NET.Plugin.AiDOP.Dto.S8;
-
-public class AdoS8ConfigDraftCreateDto
-{
-    public long TenantId { get; set; } = 1;
-    public long FactoryId { get; set; } = 1;
-    public string DraftCode { get; set; } = string.Empty;
-    public string? DraftName { get; set; }
-    public string WizardJson { get; set; } = string.Empty;
-    public int? CurrentStep { get; set; }
-    public string? Mechanism { get; set; }
-    public string? StageCode { get; set; }
-    public string? OrderFlowCode { get; set; }
-    public string? ExceptionTypeCode { get; set; }
-    public string? Remark { get; set; }
-}
-
-public class AdoS8ConfigDraftUpdateDto
-{
-    public string? DraftName { get; set; }
-    public string? WizardJson { get; set; }
-    public int? CurrentStep { get; set; }
-    public string? Mechanism { get; set; }
-    public string? StageCode { get; set; }
-    public string? OrderFlowCode { get; set; }
-    public string? ExceptionTypeCode { get; set; }
-    public string? Remark { get; set; }
-}
-
-public class AdoS8ConfigDraftListQueryDto
-{
-    public long TenantId { get; set; } = 1;
-    public long FactoryId { get; set; } = 1;
-    public string? Status { get; set; }
-    public int Page { get; set; } = 1;
-    public int PageSize { get; set; } = 20;
-}
-
-public class AdoS8ConfigDraftListItemDto
-{
-    public long Id { get; set; }
-    public long TenantId { get; set; }
-    public long FactoryId { get; set; }
-    public string DraftCode { get; set; } = string.Empty;
-    public string? DraftName { get; set; }
-    public string Status { get; set; } = string.Empty;
-    public int CurrentStep { get; set; }
-    public string? Mechanism { get; set; }
-    public string? StageCode { get; set; }
-    public string? OrderFlowCode { get; set; }
-    public string? ExceptionTypeCode { get; set; }
-    public long? GeneratedRuleId { get; set; }
-    public string? Remark { get; set; }
-    public DateTime CreatedAt { get; set; }
-    public DateTime? UpdatedAt { get; set; }
-}
-
-public class AdoS8ConfigDraftListResultDto
-{
-    public List<AdoS8ConfigDraftListItemDto> Items { get; set; } = new();
-    public int Total { get; set; }
-    public int Page { get; set; }
-    public int PageSize { get; set; }
-}
-
-public class AdoS8ConfigDraftDetailDto : AdoS8ConfigDraftListItemDto
-{
-    public string WizardJson { get; set; } = string.Empty;
-}
-
-/// <summary>
-/// CONFIG-WIZARD-API-RULEPAYLOAD-CLEANUP-1:generate-rule 请求体已收口为 {};
-/// 规则始终由后端基于 draft.wizard_json + 字典重建,不再接受前端构造的 RulePayload。
-/// </summary>
-public class AdoS8ConfigDraftGenerateRuleDto
-{
-}
-
-public class AdoS8ConfigDraftGenerateRuleResultDto
-{
-    public long RuleId { get; set; }
-    public string RuleCode { get; set; } = string.Empty;
-    public string DraftStatus { get; set; } = string.Empty;
-}

+ 28 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8BizException.cs

@@ -49,6 +49,34 @@ public sealed class S8WriteRetiredException : S8BizException
     public const string WatchRuleUpdateMessage =
         "监控规则通用整实体更新接口已退役,请使用监控规则的专用配置接口。";
 
+    /// <summary>
+    /// S8-RULE-GOVERNANCE-BATCH3:业务侧建规则入口的退役文案。
+    ///
+    /// 与 <see cref="WatchRuleUpdateMessage"/> 的区别:那条退役的是"整实体更新"这种**形式**,
+    /// 规则本身仍可配置;本条退役的是"创建规则"这件**事**本身 ——
+    /// 规则的业务语义现在由系统版本(代码定义)决定,任何调用方都不该能凭空造出一条规则。
+    /// 文案必须说清"去哪里要新规则",否则用户只会以为接口坏了。
+    /// </summary>
+    public const string WatchRuleCreateMessage =
+        "监控规则由系统版本定义,不支持通过接口创建;新规则随版本发布后会自动为各租户建立运行策略。";
+
+    /// <summary>
+    /// S8-RULE-GOVERNANCE-BATCH3:业务侧删规则入口的退役文案。
+    ///
+    /// 规则定义的生命周期属于代码版本,运行策略行的删除同样不该由页面触发。
+    /// 用户"不想用这条规则"的正确表达是**停用**,不是删除 ——
+    /// 删除会连带丢掉该租户已经调好的参数与运行历史,且下次供给对账又会把它建回来。
+    /// </summary>
+    public const string WatchRuleDeleteMessage =
+        "监控规则由系统版本定义,不支持删除;如需停止该规则,请将其停用。";
+
+    /// <summary>
+    /// S8-RULE-GOVERNANCE-BATCH3:配置向导草稿整体退役的文案。
+    /// 草稿的唯一出口是"生成规则",而创建规则这件事已经不存在,草稿也就失去了全部意义。
+    /// </summary>
+    public const string ConfigDraftRetiredMessage =
+        "配置向导已下线:监控规则由系统版本定义,不再通过向导创建。";
+
     public S8WriteRetiredException() : base(UserMessage) { }
 
     /// <summary>按写入口给出各自的固定文案;调用方只能传预定义常量,不得拼入内部实现细节。</summary>

+ 0 - 631
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ConfigDraftService.cs

@@ -1,631 +0,0 @@
-using System.Globalization;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using System.Text.RegularExpressions;
-using Admin.NET.Plugin.AiDOP.Dto.S8;
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-using Admin.NET.Plugin.AiDOP.Infrastructure;
-
-using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8;
-
-internal static class S8ConfigDraftStatus
-{
-    public const string InProgress = "IN_PROGRESS";
-    public const string Generated = "GENERATED";
-}
-
-public class S8ConfigDraftService : ITransient
-{
-    private readonly SqlSugarRepository<AdoS8ConfigDraft> _rep;
-    private readonly S8WatchRuleService _watchRuleService;
-    // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:rulePayload 缺省时按 wizard_json 重建用到的字典 Repository。
-    private readonly SqlSugarRepository<AdoS8MonitorObject> _monitorObjectRep;
-    private readonly SqlSugarRepository<AdoS8MonitorMetric> _monitorMetricRep;
-    private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
-    private readonly IS8DatasetCatalog _datasetCatalog;
-    private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
-
-    public S8ConfigDraftService(
-        SqlSugarRepository<AdoS8ConfigDraft> rep,
-        S8WatchRuleService watchRuleService,
-        SqlSugarRepository<AdoS8MonitorObject> monitorObjectRep,
-        SqlSugarRepository<AdoS8MonitorMetric> monitorMetricRep,
-        SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
-        IS8DatasetCatalog datasetCatalog,
-        SqlSugarRepository<AdoS8SceneConfig> sceneRep)
-    {
-        _rep = rep;
-        _watchRuleService = watchRuleService;
-        _monitorObjectRep = monitorObjectRep;
-        _monitorMetricRep = monitorMetricRep;
-        _exceptionTypeRep = exceptionTypeRep;
-        _datasetCatalog = datasetCatalog;
-        _sceneRep = sceneRep;
-    }
-
-    // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:解析 wizard_json 用 JsonSerializerOptions(前端 camelCase)。
-    private static readonly JsonSerializerOptions WizardJsonReadOptions = new()
-    {
-        PropertyNameCaseInsensitive = true,
-        ReadCommentHandling = JsonCommentHandling.Skip,
-        AllowTrailingCommas = true,
-    };
-
-    // 后端生成 params_json 时 camelCase + 忽略 null。
-    private static readonly JsonSerializerOptions ParamsJsonWriteOptions = new()
-    {
-        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
-        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
-    };
-
-    public async Task<AdoS8ConfigDraftListResultDto> ListAsync(AdoS8ConfigDraftListQueryDto query)
-    {
-        var page = query.Page < 1 ? 1 : query.Page;
-        var pageSize = query.PageSize < 1 ? 20 : Math.Min(query.PageSize, 200);
-        var status = string.IsNullOrWhiteSpace(query.Status) ? null : query.Status.Trim();
-
-        var q = _rep.AsQueryable()
-            .Where(x => x.TenantId == query.TenantId && x.FactoryId == query.FactoryId);
-        if (status != null)
-            q = q.Where(x => x.Status == status);
-
-        RefAsync<int> total = 0;
-        var rows = await q
-            .OrderBy(x => x.Id, OrderByType.Desc)
-            .Select(x => new AdoS8ConfigDraftListItemDto
-            {
-                Id = x.Id,
-                TenantId = x.TenantId,
-                FactoryId = x.FactoryId,
-                DraftCode = x.DraftCode,
-                DraftName = x.DraftName,
-                Status = x.Status,
-                CurrentStep = x.CurrentStep,
-                Mechanism = x.Mechanism,
-                StageCode = x.StageCode,
-                OrderFlowCode = x.OrderFlowCode,
-                ExceptionTypeCode = x.ExceptionTypeCode,
-                GeneratedRuleId = x.GeneratedRuleId,
-                Remark = x.Remark,
-                CreatedAt = x.CreatedAt,
-                UpdatedAt = x.UpdatedAt
-            })
-            .ToPageListAsync(page, pageSize, total);
-
-        return new AdoS8ConfigDraftListResultDto
-        {
-            Items = rows,
-            Total = total.Value,
-            Page = page,
-            PageSize = pageSize
-        };
-    }
-
-    // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在。
-    public async Task<AdoS8ConfigDraftDetailDto> GetAsync(long id, S8TrustedScope scope)
-    {
-        var e = await LoadScopedAsync(id, scope);
-        return ToDetail(e);
-    }
-
-    /// <summary>按 Id + 可信作用域取草稿;不在作用域内一律按「不存在」处理。</summary>
-    private async Task<AdoS8ConfigDraft> LoadScopedAsync(long id, S8TrustedScope scope) =>
-        await _rep.AsQueryable()
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
-            .FirstAsync() ?? throw new S8NotFoundException("草稿不存在");
-
-    // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 dto.TenantId / dto.FactoryId。
-    public async Task<AdoS8ConfigDraftDetailDto> CreateAsync(AdoS8ConfigDraftCreateDto dto, S8TrustedScope scope)
-    {
-        if (string.IsNullOrWhiteSpace(dto.DraftCode))
-            throw new S8BizException("草稿编码必填");
-        if (string.IsNullOrWhiteSpace(dto.WizardJson))
-            throw new S8BizException("草稿 wizard_json 必填");
-
-        var draftCode = dto.DraftCode.Trim();
-        var exists = await _rep.AsQueryable()
-            .AnyAsync(x => x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId && x.DraftCode == draftCode);
-        if (exists) throw new S8BizException("草稿编码已存在");
-
-        var entity = new AdoS8ConfigDraft
-        {
-            TenantId = scope.TenantId,
-            FactoryId = scope.FactoryId,
-            DraftCode = draftCode,
-            DraftName = NormalizeOrNull(dto.DraftName),
-            WizardJson = dto.WizardJson,
-            CurrentStep = dto.CurrentStep ?? 0,
-            Mechanism = NormalizeOrNull(dto.Mechanism),
-            StageCode = NormalizeOrNull(dto.StageCode),
-            OrderFlowCode = NormalizeOrNull(dto.OrderFlowCode),
-            ExceptionTypeCode = NormalizeOrNull(dto.ExceptionTypeCode),
-            Remark = NormalizeOrNull(dto.Remark),
-            Status = S8ConfigDraftStatus.InProgress,
-            CreatedAt = DateTime.Now
-        };
-        entity.Id = await _rep.AsInsertable(entity).ExecuteReturnBigIdentityAsync();
-        return ToDetail(entity);
-    }
-
-    public async Task<AdoS8ConfigDraftDetailDto> UpdateAsync(long id, AdoS8ConfigDraftUpdateDto dto, S8TrustedScope scope)
-    {
-        var e = await LoadScopedAsync(id, scope);
-        if (e.Status == S8ConfigDraftStatus.Generated)
-            throw new S8BizException("草稿已生成正式规则,禁止再编辑");
-
-        if (dto.DraftName != null) e.DraftName = NormalizeOrNull(dto.DraftName);
-        if (dto.WizardJson != null)
-        {
-            if (string.IsNullOrWhiteSpace(dto.WizardJson))
-                throw new S8BizException("wizard_json 不能为空字符串");
-            e.WizardJson = dto.WizardJson;
-        }
-        if (dto.CurrentStep.HasValue) e.CurrentStep = dto.CurrentStep.Value;
-        if (dto.Mechanism != null) e.Mechanism = NormalizeOrNull(dto.Mechanism);
-        if (dto.StageCode != null) e.StageCode = NormalizeOrNull(dto.StageCode);
-        if (dto.OrderFlowCode != null) e.OrderFlowCode = NormalizeOrNull(dto.OrderFlowCode);
-        if (dto.ExceptionTypeCode != null) e.ExceptionTypeCode = NormalizeOrNull(dto.ExceptionTypeCode);
-        if (dto.Remark != null) e.Remark = NormalizeOrNull(dto.Remark);
-
-        e.UpdatedAt = DateTime.Now;
-        await _rep.UpdateAsync(e);
-        return ToDetail(e);
-    }
-
-    public async Task DeleteAsync(long id, S8TrustedScope scope)
-    {
-        var e = await LoadScopedAsync(id, scope);
-        await _rep.DeleteByIdAsync(e.Id);
-    }
-
-    public async Task<AdoS8ConfigDraftGenerateRuleResultDto> GenerateRuleAsync(long id, AdoS8ConfigDraftGenerateRuleDto dto, S8TrustedScope scope)
-    {
-        var draft = await LoadScopedAsync(id, scope);
-
-        if (draft.GeneratedRuleId.HasValue)
-        {
-            return new AdoS8ConfigDraftGenerateRuleResultDto
-            {
-                RuleId = draft.GeneratedRuleId.Value,
-                RuleCode = await ResolveExistingRuleCodeAsync(draft.GeneratedRuleId.Value),
-                DraftStatus = draft.Status
-            };
-        }
-        if (draft.Status == S8ConfigDraftStatus.Generated)
-            throw new S8BizException("草稿状态异常:已标记 GENERATED 但未回写规则 ID");
-
-        // CONFIG-WIZARD-API-RULEPAYLOAD-CLEANUP-1:始终由后端基于 wizard_json + 字典重建,dto 已不再承载 RulePayload。
-        var payload = await RebuildPayloadFromWizardJsonAsync(draft);
-        if (string.IsNullOrWhiteSpace(payload.RuleCode))
-            throw new S8BizException("规则编码不能为空");
-
-        // 安全收口:强制对齐草稿租户/工厂、强制 enabled=false。
-        payload.Id = 0;
-        payload.TenantId = draft.TenantId;
-        payload.FactoryId = draft.FactoryId;
-        payload.Enabled = false;
-
-        var db = _rep.Context;
-        await db.Ado.BeginTranAsync();
-        try
-        {
-            // S8-TENANT-FACTORY-P0-CLOSURE-1:草稿已按可信作用域绑行,生成的规则沿用同一作用域。
-            //
-            // 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;
-            if (newRuleId <= 0)
-            {
-                newRuleId = await db.Queryable<AdoS8WatchRule>()
-                    .Where(x => x.TenantId == draft.TenantId
-                                && x.FactoryId == draft.FactoryId
-                                && x.RuleCode == created.RuleCode)
-                    .Select(x => x.Id)
-                    .FirstAsync();
-            }
-            if (newRuleId <= 0)
-                throw new S8BizException("生成规则失败:无法定位新规则 id");
-
-            await db.Updateable<AdoS8ConfigDraft>()
-                .SetColumns(x => new AdoS8ConfigDraft
-                {
-                    GeneratedRuleId = newRuleId,
-                    Status = S8ConfigDraftStatus.Generated,
-                    UpdatedAt = DateTime.Now
-                })
-                .Where(x => x.Id == draft.Id)
-                .ExecuteCommandAsync();
-
-            await db.Ado.CommitTranAsync();
-
-            return new AdoS8ConfigDraftGenerateRuleResultDto
-            {
-                RuleId = newRuleId,
-                RuleCode = created.RuleCode,
-                DraftStatus = S8ConfigDraftStatus.Generated
-            };
-        }
-        catch
-        {
-            await db.Ado.RollbackTranAsync();
-            throw;
-        }
-    }
-
-    private async Task<string> ResolveExistingRuleCodeAsync(long ruleId)
-    {
-        var rule = await _rep.Context.Queryable<AdoS8WatchRule>()
-            .Where(x => x.Id == ruleId)
-            .Select(x => new { x.RuleCode })
-            .FirstAsync();
-        return rule?.RuleCode ?? string.Empty;
-    }
-
-    private static AdoS8ConfigDraftDetailDto ToDetail(AdoS8ConfigDraft e) => new()
-    {
-        Id = e.Id,
-        TenantId = e.TenantId,
-        FactoryId = e.FactoryId,
-        DraftCode = e.DraftCode,
-        DraftName = e.DraftName,
-        Status = e.Status,
-        CurrentStep = e.CurrentStep,
-        Mechanism = e.Mechanism,
-        StageCode = e.StageCode,
-        OrderFlowCode = e.OrderFlowCode,
-        ExceptionTypeCode = e.ExceptionTypeCode,
-        GeneratedRuleId = e.GeneratedRuleId,
-        Remark = e.Remark,
-        CreatedAt = e.CreatedAt,
-        UpdatedAt = e.UpdatedAt,
-        WizardJson = e.WizardJson
-    };
-
-    private static string? NormalizeOrNull(string? value)
-    {
-        if (string.IsNullOrWhiteSpace(value)) return null;
-        var trimmed = value.Trim();
-        return trimmed.Length == 0 ? null : trimmed;
-    }
-
-    // ============================================================
-    // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:基于 wizard_json + 字典重建 AdoS8WatchRule
-    // ============================================================
-
-    /// <summary>
-    /// 从草稿的 wizard_json 解析向导态,结合 monitor object/metric、exception_type、data_source、scene_config
-    /// 重建 AdoS8WatchRule。所有错误统一抛 S8BizException → Controller 转 400。
-    /// </summary>
-    private async Task<AdoS8WatchRule> RebuildPayloadFromWizardJsonAsync(AdoS8ConfigDraft draft)
-    {
-        if (string.IsNullOrWhiteSpace(draft.WizardJson))
-            throw new S8BizException("草稿配置内容为空");
-
-        S8WizardJsonV1 parsed;
-        try
-        {
-            parsed = JsonSerializer.Deserialize<S8WizardJsonV1>(draft.WizardJson, WizardJsonReadOptions)
-                     ?? throw new S8BizException("草稿配置内容为空");
-        }
-        catch (JsonException)
-        {
-            throw new S8BizException("草稿配置内容格式错误");
-        }
-
-        if (parsed.Version != 1)
-            throw new S8BizException($"暂不支持该草稿版本(version={parsed.Version})");
-
-        var form = parsed.Form ?? throw new S8BizException("草稿配置内容不完整");
-        var labels = parsed.Labels;
-
-        // 必填基础字段
-        var ruleCode = NormalizeOrNull(form.RuleCode) ?? throw new S8BizException("规则编码不能为空");
-        var mechanism = NormalizeOrNull(form.Mechanism) ?? throw new S8BizException("报警机制未选择");
-        if (mechanism == "MANUAL_REPORT")
-            throw new S8BizException("主动提报无需生成自动监控规则");
-        var stageCode = NormalizeOrNull(form.StageCode) ?? throw new S8BizException("阶段维度未选择");
-        var exceptionTypeCode = NormalizeOrNull(form.ExceptionTypeCode) ?? throw new S8BizException("异常类型未选择");
-        var objectType = NormalizeOrNull(form.ObjectType) ?? throw new S8BizException("监控对象未选择");
-        var objectLabel = NormalizeOrNull(form.ObjectLabel) ?? throw new S8BizException("监控对象未选择");
-        var metricCode = NormalizeOrNull(form.MetricCode) ?? throw new S8BizException("监控指标未选择");
-
-        // 监控对象(用 objectType + objectName 在字典里反查;tenant 覆盖 baseline)
-        var monitorObject = await ResolveMonitorObjectAsync(draft.TenantId, draft.FactoryId, objectType, objectLabel);
-
-        // 监控指标
-        var metric = await ResolveMonitorMetricAsync(draft.TenantId, draft.FactoryId, metricCode, mechanism);
-
-        // CONFIG-WIZARD-T3K-ORDER-FLOW-STAGE-VALIDATE-1:销售订单链路阶段额外协议校验
-        if (string.Equals(monitorObject.ObjectCode, "SALES_ORDER_FLOW_STAGE", StringComparison.Ordinal))
-        {
-            if (string.IsNullOrWhiteSpace(form.OrderFlowCode))
-                throw new S8BizException("订单链路阶段规则必须选择订单流程节点");
-            var ocf = NormalizeOrNull(metric.ObjectCodeField);
-            if (!string.Equals(ocf, "order_code", StringComparison.OrdinalIgnoreCase))
-                throw new S8BizException("订单链路阶段规则的 objectCodeField 必须为 order_code");
-        }
-
-        // 异常类型(baseline 0/0 + tenant 覆盖;enabled 必须 true;sceneCode 必须等于 stageCode)
-        var exceptionType = await ResolveExceptionTypeAsync(draft.TenantId, draft.FactoryId, exceptionTypeCode);
-        if (!string.IsNullOrWhiteSpace(exceptionType.SceneCode) && exceptionType.SceneCode != stageCode)
-            throw new S8BizException("异常类型所属阶段与当前规则阶段不一致");
-
-        // severity 优先 wizard_json,再 exceptionType.SeverityDefault,再 FOLLOW
-        var severity = NormalizeOrNull(form.Severity)
-                       ?? NormalizeOrNull(exceptionType.SeverityDefault)
-                       ?? "FOLLOW";
-
-        // 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()
-            .FirstAsync(x => x.TenantId == draft.TenantId && x.FactoryId == draft.FactoryId && x.SceneCode == stageCode)
-            ?? throw new S8BizException("规则所属场景不存在");
-        if (!scene.Enabled) throw new S8BizException("规则所属场景未启用");
-
-        var paramsJson = BuildParamsJson(form, labels, mechanism, metric, monitorObject, exceptionTypeCode);
-
-        return new AdoS8WatchRule
-        {
-            TenantId = draft.TenantId,
-            FactoryId = draft.FactoryId,
-            RuleCode = ruleCode,
-            SceneCode = stageCode,
-            DatasetCode = datasetCode,
-            WatchObjectType = monitorObject.ObjectType,
-            RuleType = RuleTypeOf(mechanism),
-            RuleMechanism = mechanism,
-            StageCode = stageCode,
-            OrderFlowCode = NormalizeOrNull(form.OrderFlowCode),
-            SourceObjectType = monitorObject.ObjectType,
-            Severity = severity,
-            ParamsJson = paramsJson,
-            PollIntervalSeconds = form.PollIntervalSeconds is > 0 ? form.PollIntervalSeconds.Value : 300,
-            TriggerCountRequired = form.TriggerCountRequired is > 0 ? form.TriggerCountRequired.Value : 1,
-            RecoverCountRequired = form.RecoverCountRequired is > 0 ? form.RecoverCountRequired.Value : 1,
-            ConsecutiveFailureCount = 0,
-            Enabled = false,
-        };
-    }
-
-    private async Task<AdoS8MonitorObject> ResolveMonitorObjectAsync(long tenantId, long factoryId, string objectType, string objectName)
-    {
-        var rows = await _monitorObjectRep.AsQueryable()
-            .Where(x => x.ObjectType == objectType && x.ObjectName == objectName
-                && ((x.TenantId == 0 && x.FactoryId == 0)
-                    || (x.TenantId == tenantId && x.FactoryId == factoryId)))
-            .ToListAsync();
-        var picked = rows
-            .OrderByDescending(x => x.FactoryId)
-            .ThenByDescending(x => x.TenantId)
-            .FirstOrDefault();
-        if (picked == null || !picked.Enabled)
-            throw new S8BizException($"监控对象 '{objectName}' 不存在或未启用");
-        return picked;
-    }
-
-    private async Task<AdoS8MonitorMetric> ResolveMonitorMetricAsync(long tenantId, long factoryId, string metricCode, string mechanism)
-    {
-        var rows = await _monitorMetricRep.AsQueryable()
-            .Where(x => x.MetricCode == metricCode
-                && ((x.TenantId == 0 && x.FactoryId == 0)
-                    || (x.TenantId == tenantId && x.FactoryId == factoryId)))
-            .ToListAsync();
-        var picked = rows
-            .OrderByDescending(x => x.FactoryId)
-            .ThenByDescending(x => x.TenantId)
-            .FirstOrDefault();
-        if (picked == null)
-            throw new S8BizException($"监控指标 '{metricCode}' 不存在");
-        if (!picked.Enabled)
-        {
-            var label = string.IsNullOrWhiteSpace(picked.MetricName) ? picked.MetricCode : picked.MetricName;
-            throw new S8BizException($"监控指标 '{label}' 未启用,请先在监控指标字典中启用后再生成规则");
-        }
-        if (!string.IsNullOrWhiteSpace(picked.Mechanism) && picked.Mechanism != mechanism)
-            throw new S8BizException("监控指标与报警机制不匹配");
-        return picked;
-    }
-
-    private async Task<AdoS8ExceptionType> ResolveExceptionTypeAsync(long tenantId, long factoryId, string typeCode)
-    {
-        var rows = await _exceptionTypeRep.AsQueryable()
-            .Where(x => x.TypeCode == typeCode
-                && ((x.TenantId == 0 && x.FactoryId == 0)
-                    || (x.TenantId == tenantId && x.FactoryId == factoryId)))
-            .ToListAsync();
-        var picked = rows.OrderByDescending(x => x.FactoryId).ThenByDescending(x => x.TenantId).FirstOrDefault();
-        if (picked == null) throw new S8BizException($"异常类型 '{typeCode}' 不存在");
-        if (!picked.Enabled) throw new S8BizException($"异常类型 '{picked.TypeName}' 未启用");
-        return picked;
-    }
-
-    private static string RuleTypeOf(string mechanism) => mechanism switch
-    {
-        "DATE" => "TIMEOUT",
-        "VALUE_RANGE" => "OUT_OF_RANGE",
-        "RATIO" => "OUT_OF_RANGE",
-        "MANUAL_REPORT" => throw new S8BizException("主动提报无需生成自动监控规则"),
-        _ => throw new S8BizException($"不支持的报警机制:{mechanism}"),
-    };
-
-    // 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,
-        S8WizardLabelsV1? labels,
-        string mechanism,
-        AdoS8MonitorMetric metric,
-        AdoS8MonitorObject monitorObject,
-        string exceptionTypeCode)
-    {
-        var objectLabel = NormalizeOrNull(labels?.ObjectLabel)
-                          ?? NormalizeOrNull(form.ObjectLabel)
-                          ?? monitorObject.ObjectName;
-        var metricLabel = NormalizeOrNull(labels?.MetricLabel)
-                          ?? NormalizeOrNull(form.MetricLabel)
-                          ?? metric.MetricName;
-        var unit = NormalizeOrNull(form.Unit) ?? metric.Unit;
-        var thresholdDisplay = NormalizeOrNull(labels?.ThresholdDisplay)
-                               ?? BuildThresholdDisplay(mechanism, form, metric, unit);
-
-        var objectIdField = NormalizeOrNull(metric.ObjectIdField) ?? "source_object_id";
-        var objectCodeField = NormalizeOrNull(metric.ObjectCodeField) ?? "related_object_code";
-        var objectNameField = NormalizeOrNull(metric.ObjectNameField) ?? "related_object_name";
-
-        var dict = new Dictionary<string, object?>
-        {
-            ["objectIdField"] = objectIdField,
-            ["objectCodeField"] = objectCodeField,
-            ["objectNameField"] = objectNameField,
-            ["exceptionTypeCode"] = exceptionTypeCode,
-            ["objectLabel"] = objectLabel,
-            ["metricLabel"] = metricLabel,
-        };
-
-        if (mechanism == "DATE")
-        {
-            var states = ParseCsvStates(form.CompletedStates);
-            if (states.Count == 0) states = ParseCsvStates(metric.DefaultCompletedStates);
-            if (states.Count == 0) states = new List<string> { "CLOSED", "COMPLETED", "DONE" };
-
-            var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
-            dict["dueAtField"] = NormalizeOrNull(metric.DueAtField) ?? "due_at";
-            dict["statusField"] = NormalizeOrNull(metric.StatusField) ?? "status";
-            dict["completedStates"] = states;
-            dict["graceMinutes"] = grace;
-            dict["unit"] = unit ?? "分钟";
-            dict["thresholdDisplay"] = thresholdDisplay;
-        }
-        else if (mechanism == "VALUE_RANGE")
-        {
-            var lower = form.LowerBound ?? metric.DefaultLowerBound;
-            var upper = form.UpperBound ?? metric.DefaultUpperBound;
-            if (lower == null && upper == null)
-                throw new S8BizException("请配置上限或下限至少其一");
-            dict["measuredValueField"] = NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value";
-            dict["unit"] = unit;
-            dict["thresholdDisplay"] = thresholdDisplay;
-            if (lower.HasValue) dict["lowerBound"] = lower.Value;
-            if (upper.HasValue) dict["upperBound"] = upper.Value;
-            if (form.ToleranceAbs is > 0) dict["toleranceAbs"] = form.ToleranceAbs.Value;
-            if (form.ToleranceRatioPct is > 0) dict["toleranceRatio"] = form.ToleranceRatioPct.Value / 100m;
-        }
-        else if (mechanism == "RATIO")
-        {
-            var target = form.TargetRatio ?? metric.DefaultTargetRatio;
-            if (!target.HasValue) throw new S8BizException("请配置目标比例");
-            dict["measuredValueField"] = NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value";
-            dict["lowerBound"] = target.Value;
-            dict["unit"] = "%";
-            dict["thresholdDisplay"] = thresholdDisplay;
-        }
-        else
-        {
-            throw new S8BizException($"不支持的报警机制:{mechanism}");
-        }
-
-        return JsonSerializer.Serialize(dict, ParamsJsonWriteOptions);
-    }
-
-    private static List<string> ParseCsvStates(string? csv)
-    {
-        if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
-        return csv.Split(new[] { ',', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
-                  .ToList();
-    }
-
-    private static string BuildThresholdDisplay(string mechanism, S8WizardFormV1 form, AdoS8MonitorMetric metric, string? unit)
-    {
-        var u = unit ?? string.Empty;
-        if (mechanism == "DATE")
-        {
-            var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
-            return grace > 0 ? $"超期 {grace} 分钟仍未完成触发" : "超期即触发";
-        }
-        if (mechanism == "RATIO")
-        {
-            var target = form.TargetRatio ?? metric.DefaultTargetRatio;
-            return target.HasValue
-                ? string.Format(CultureInfo.InvariantCulture, "低于 {0}% 触发", target.Value)
-                : "低于目标比例触发";
-        }
-        if (mechanism == "VALUE_RANGE")
-        {
-            var lower = form.LowerBound ?? metric.DefaultLowerBound;
-            var upper = form.UpperBound ?? metric.DefaultUpperBound;
-            var segs = new List<string>();
-            if (lower.HasValue) segs.Add(string.Format(CultureInfo.InvariantCulture, "低于 {0}{1}", lower.Value, u));
-            if (upper.HasValue) segs.Add(string.Format(CultureInfo.InvariantCulture, "高于 {0}{1}", upper.Value, u));
-            return segs.Count > 0 ? string.Join(" 或 ", segs) + " 触发" : "超出设定范围触发";
-        }
-        return string.Empty;
-    }
-
-    // ----- wizard_json 解析私有 DTO -----
-
-    private sealed class S8WizardJsonV1
-    {
-        public int Version { get; set; }
-        public int Step { get; set; }
-        public S8WizardFormV1? Form { get; set; }
-        public S8WizardLabelsV1? Labels { get; set; }
-    }
-
-    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; }
-        public string? Severity { get; set; }
-        public string? ObjectType { get; set; }
-        public string? ObjectLabel { get; set; }
-        public string? MetricCode { get; set; }
-        public string? MetricLabel { get; set; }
-        public string? Unit { get; set; }
-        public int? GraceMinutes { get; set; }
-        // 前端 wizard_json 中 completedStates 是 CSV 字符串(如 "CLOSED,COMPLETED,DONE"),不是数组
-        public string? CompletedStates { get; set; }
-        public decimal? LowerBound { get; set; }
-        public decimal? UpperBound { get; set; }
-        public decimal? ToleranceAbs { get; set; }
-        public decimal? ToleranceRatioPct { get; set; }
-        public decimal? TargetRatio { get; set; }
-        public string? RuleCode { get; set; }
-        public int? PollIntervalSeconds { get; set; }
-        public int? TriggerCountRequired { get; set; }
-        public int? RecoverCountRequired { get; set; }
-    }
-
-    private sealed class S8WizardLabelsV1
-    {
-        public string? MechanismLabel { get; set; }
-        public string? StageLabel { get; set; }
-        public string? ObjectLabel { get; set; }
-        public string? MetricLabel { get; set; }
-        public string? ThresholdDisplay { get; set; }
-    }
-}

+ 44 - 135
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs

@@ -11,21 +11,15 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 public class S8WatchRuleService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8WatchRule> _rep;
-    private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
-    private readonly IS8DatasetCatalog _datasetCatalog;
     private readonly S8DatasetEnableGate _datasetEnableGate;
     private readonly IS8RuleCatalog _ruleCatalog;
 
     public S8WatchRuleService(
         SqlSugarRepository<AdoS8WatchRule> rep,
-        SqlSugarRepository<AdoS8SceneConfig> sceneRep,
-        IS8DatasetCatalog datasetCatalog,
         S8DatasetEnableGate datasetEnableGate,
         IS8RuleCatalog ruleCatalog)
     {
         _rep = rep;
-        _sceneRep = sceneRep;
-        _datasetCatalog = datasetCatalog;
         _datasetEnableGate = datasetEnableGate;
         _ruleCatalog = ruleCatalog;
     }
@@ -35,27 +29,27 @@ public class S8WatchRuleService : ITransient
             .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
             .ToListAsync();
 
-    // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
+    // ================================================================================
+    // S8-RULE-GOVERNANCE-BATCH3:业务侧建规则能力已退役。
     //
-    // 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;
-        await ValidateAsync(body, scope);
-        // S8-STEP6E:新建一律走 canonical 词表 + params schema,杜绝「存得下但运行时永远不生效」。
-        ValidateVocabularyForCreate(body);
-        if (!string.IsNullOrWhiteSpace(body.ParamsJson))
-            ValidateParamsJsonShape(body.ParamsJson!.Trim());
-        body.Id = 0;
-        body.CreatedAt = DateTime.Now;
-        // S8-STEP6E(与 CFG_DATASRC D-3 / CFG_ROLES 同源缺陷):回填自增主键。
-        // 原 InsertAsync 只返回 bool,body.Id 保持 0,调用方随后 GET/PUT/DELETE 一律 404。
-        body.Id = await _rep.AsInsertable(body).ExecuteReturnBigIdentityAsync();
-        return body;
-    }
+    // 原实现是「客户端传一个 AdoS8WatchRule → 服务端盖章归属 → 校验词表 → 落库」,
+    // 即调用方可以凭空定义一条规则的 rule_code / dataset_code / rule_type /
+    // source_object_type / scene_code,再配一段 params_json 决定它判什么。
+    // 那正是 Batch 1 要消灭的形态:规则的业务语义必须来自代码定义,不能来自请求体。
+    //
+    // 替代路径(Batch 2 已建立,且**不经过本方法**):
+    //     S8RuleCatalog(代码定义)
+    //       → S8RuleProvisioningService(系统内部写路径)
+    //       → 每租户一条 runtime policy,默认停用
+    //
+    // ⚠️ 抛出发生在**任何 DB 访问之前**:不查重、不读场景、不碰仓储。
+    //    这既保证零写入,也让「能力没了」这件事与入参是否合法无关。
+    //
+    // 保留方法名而非整个删掉:S8RuleCreationRetiredTests 用反射断言
+    // 「该名字存在但恒抛退役异常」,比断言「名字不存在」更能防住有人换个名字复活它。
+    // ================================================================================
+    public Task<AdoS8WatchRule> CreateAsync(AdoS8WatchRule body, S8TrustedScope scope) =>
+        throw new S8WriteRetiredException(S8WriteRetiredException.WatchRuleCreateMessage);
 
     // ================================================================================
     // S8-STEP6E-CFG-WATCH-FIX-AND-SAFE-CERT-1:通用整实体 PUT 已退役。
@@ -90,12 +84,21 @@ public class S8WatchRuleService : ITransient
     public Task<AdoS8WatchRule> UpdateAsync(long id, AdoS8WatchRule body, S8TrustedScope scope) =>
         throw new S8WriteRetiredException(S8WriteRetiredException.WatchRuleUpdateMessage);
 
-    // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。
-    public async Task DeleteAsync(long id, S8TrustedScope scope)
-    {
-        var e = await LoadScopedAsync(id, scope);
-        await _rep.DeleteByIdAsync(e.Id);
-    }
+    // ================================================================================
+    // S8-RULE-GOVERNANCE-BATCH3:业务侧删规则能力已退役。
+    //
+    // 规则定义的生命周期属于代码版本;运行策略行的删除同样不该由页面触发:
+    //   · 删掉会连带丢失该租户已调好的参数与运行历史(含 recovered/抗抖计数);
+    //   · 下一次供给对账又会把它按默认值建回来 —— 用户看到的是"删了又回来了";
+    //   · 真正的诉求「不想让这条规则再跑」已经有正确表达:**停用**。
+    //
+    // 规则撤销的唯一正当路径是代码版本移除 Definition,随后由治理流程处理 orphan 行
+    //(Provisioning 的 OrphanedCount 会持续上报,Scheduler 以 rule_definition_not_found 拒绝执行)。
+    //
+    // ⚠️ 与 CreateAsync 同理:抛出在任何 DB 访问之前,任意 id(含越权 id)一律 410 而非 404。
+    // ================================================================================
+    public Task DeleteAsync(long id, S8TrustedScope scope) =>
+        throw new S8WriteRetiredException(S8WriteRetiredException.WatchRuleDeleteMessage);
 
     /// <summary>按 Id + 可信作用域取行;不在作用域内一律按「不存在」处理,不泄露他租户资源是否存在。</summary>
     private async Task<AdoS8WatchRule> LoadScopedAsync(long id, S8TrustedScope scope) =>
@@ -277,81 +280,15 @@ public class S8WatchRuleService : ITransient
         return await LoadScopedAsync(id, scope);
     }
 
-    /// <summary>
-    /// S8-STEP6E:WATCH 侧 canonical 词表,**严格取自真实 evaluator dispatch**
-    /// (S8WatchSchedulerService.RunSingleRuleAsync 的 switch,ordinal 大小写敏感),
-    /// 不是从 NOTIFY 词表套用过来的。
-    /// 注意 rule_type 为空/空白是**合法的历史态**(调度器按 rule_type_empty_skipped 跳过、
-    /// 不算失败),但**不允许新建**——新建一条永远不会被任何 evaluator 承载的规则没有产品意义。
-    /// </summary>
-    private static readonly string[] CanonicalRuleTypes =
-    {
-        S8TimeoutRuleEvaluator.RuleTypeCode,
-        S8ShortageRuleEvaluator.RuleTypeCode,
-        S8OutOfRangeRuleEvaluator.RuleTypeCode,
-    };
-
-    /// <summary>
-    /// S8-STEP6E:新建时的 canonical 词表校验(rule_type / scene / severity)。
-    ///
-    /// 只作用于 Create:
-    ///   · UpdateParamsAsync / UpdateScheduleAsync / Pause / Resume 都不修改这三个字段,无需重复校验;
-    ///   · TestAsync 是对**既有行**的探针,若在此加严会让 legacy 无效行连自检都跑不了。
-    /// 即「legacy 无效数据允许读取与停用,但禁止继续创建」。
-    /// </summary>
-    private static void ValidateVocabularyForCreate(AdoS8WatchRule body)
-    {
-        // rule_type:必须是真实存在 evaluator 的三类之一。
-        if (string.IsNullOrWhiteSpace(body.RuleType)
-            || Array.IndexOf(CanonicalRuleTypes, body.RuleType) < 0)
-            throw new S8BizException(
-                "不支持的规则类型:" + (string.IsNullOrWhiteSpace(body.RuleType) ? "(空)" : body.RuleType)
-                + ";当前仅支持 " + string.Join(" / ", CanonicalRuleTypes));
-
-        // scene:S8SceneCode 本身没有 IsValid,canonical 单模块场景判定复用 S8ModuleCode.IsValid
-        //(仓内唯一「严格 S1–S7、拒 legacy 复合场景」的现成实现,不另造第二套)。
-        if (!S8ModuleCode.IsValid(body.SceneCode))
-            throw new S8BizException(
-                "不支持的场景编码:" + body.SceneCode + ";当前仅支持 " + string.Join(" / ", S8ModuleCode.All));
-
-        // severity:必须在 S8SeverityCode.Normalize **之前**校验。
-        // Normalize 的兜底分支是 `_ => Follow`,放在之后会把 HIGH / 拼写错误静默降级成 FOLLOW,
-        // 门禁永远命中不了——DB 中 severity='HIGH' 的那一行正是这样进来的。
-        // 也刻意不复用 S8SeverityCode.IsValid:那是宽松六值版(含 LOW/MEDIUM/HIGH/CRITICAL),
-        // 供 legacy 查询参数兼容用,拿来当写入门禁会直接放行 legacy 值。
-        if (!string.Equals(body.Severity, S8SeverityCode.Follow, StringComparison.Ordinal)
-            && !string.Equals(body.Severity, S8SeverityCode.Serious, StringComparison.Ordinal))
-            throw new S8BizException(
-                "不支持的严重度:" + body.Severity + ";当前仅支持 "
-                + S8SeverityCode.Follow + " / " + S8SeverityCode.Serious);
-    }
-
-    /// <summary>
-    /// S8-RULE-GOVERNANCE-BATCH1:params_json 的按类型 schema 校验已退役。
-    ///
-    /// <para>它校验的是 <c>dueAtField</c> / <c>statusField</c> / <c>exceptionTypeCode</c> 这些
-    /// **A 类判定语义**是否齐备 —— 而这些字段现在只存在于代码定义里,params_json 根本不再承载它们。
-    /// 继续校验等于要求调用方提交一份已经没有意义的 JSON。</para>
-    ///
-    /// <para>保留 JSON 合法性检查:脏文本存进去会让运行期解析失败,
-    /// 虽然 <see cref="S8RuleRuntimeParameters.Resolve"/> 会回落默认值而不报错,
-    /// 但让一份读不懂的文本静静躺在库里没有任何好处。</para>
-    /// </summary>
-    private static void ValidateParamsJsonShape(string paramsJson)
-    {
-        try { using (JsonDocument.Parse(paramsJson)) { } }
-        catch (JsonException ex)
-        {
-            throw new S8BizException($"params_json 不是合法 JSON:{ex.Message}");
-        }
-    }
-
-    public async Task<object> TestAsync(long id, S8TrustedScope scope)
-    {
-        var entity = await LoadScopedAsync(id, scope);
-        await ValidateAsync(entity, scope, id);
-        return new { id, success = true, message = "规则基础校验通过", pollIntervalSeconds = entity.PollIntervalSeconds };
-    }
+    // S8-RULE-GOVERNANCE-BATCH3:以下三段随建规则能力一并删除 ——
+    //   · CanonicalRuleTypes / ValidateVocabularyForCreate:校验的是"调用方传来的 rule_type、
+    //     scene_code、severity 是否在词表内",而这三项现在只可能来自代码定义,
+    //     由 S8RuleCatalog 在构造期做更严格的校验(含 ordinal 精确匹配);
+    //   · ValidateParamsJsonShape:校验的是调用方提交的 params_json,
+    //     而 params_json 已不再由任何接口整块写入;
+    //   · ValidateAsync:唯一调用方是已退役的 CreateAsync / TestAsync,且它要求
+    //     ado_s8_scene_config 存在对应场景行 —— 对代码定义的规则那是一条无关的额外前提。
+    // 留着它们只会让人以为还存在"提交规则定义"这条路。
 
     // S8-SCHED-FRONTEND-1:远未来手工暂停哨兵值(与 SqlSugar DateTime 兼容;前端按 paused_until > now 判定)。
     private static readonly DateTime ManualPausedSentinel = new(9999, 12, 31, 23, 59, 59);
@@ -438,32 +375,4 @@ public class S8WatchRuleService : ITransient
             .ExecuteCommandAsync();
         return new { id, resumed = true, message = "已恢复,并将在下一轮调度中执行" };
     }
-
-    private async Task ValidateAsync(AdoS8WatchRule body, S8TrustedScope scope, long? id = null)
-    {
-        if (string.IsNullOrWhiteSpace(body.RuleCode) || string.IsNullOrWhiteSpace(body.SceneCode))
-            throw new S8BizException("规则编码和场景编码必填");
-
-        var exists = await _rep.AsQueryable()
-            .AnyAsync(x => x.Id != (id ?? 0) && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.RuleCode == body.RuleCode);
-        if (exists) throw new S8BizException("监视规则编码已存在");
-
-        // 保存级校验(dataset_code 结构 + 目录归属,不要求 Provider 已上线)。
-        S8WatchRuleDataAccessValidator.ValidateForSave(body, _datasetCatalog);
-
-        // 只有真正要启用时才要求完整运行条件;草稿 / disabled 规则允许 Provider 未上线。
-        // S8-RULE-GOVERNANCE-BATCH1:启用条件按**代码定义**判定,不按 body 上的投影列。
-        if (body.Enabled)
-        {
-            var definition = _ruleCatalog.GetRequired(body.RuleCode);
-            _datasetEnableGate.EnsureCanEnable(
-                definition.DatasetCode, definition.RuleType, definition.RuleCode, scope.TenantId, scope.FactoryId);
-        }
-
-        var scene = await _sceneRep.GetFirstAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.SceneCode == body.SceneCode)
-            ?? throw new S8BizException("关联场景不存在");
-        if (!scene.Enabled) throw new S8BizException("关联场景未启用");
-
-        if (body.PollIntervalSeconds <= 0) throw new S8BizException("轮询间隔必须大于 0");
-    }
 }