瀏覽代碼

refactor(s8)!: 物理删除报警规则功能与 evaluator 的 alertRules 死参数

Hard Cutover 第一步。上一阶段只做到「写入口 410 + 入口下线」,本阶段是彻底删除:
不保留只读 API、不保留表、不保留 410 兼容层。

为什么可以删(三条互相独立的取证)
1. 三个 evaluator 的 IReadOnlyList<AdoS8AlertRule> alertRules 形参,方法体读取次数 = 0;
2. 调度器每 tick 查一次该表后转手丢弃(NO-OP READ),两处装载点均已删除;
3. 全表 1 行(G01_TEST_ALERT,scene 'S2S6_PRODUCTION' 是已废弃的复合场景码),
   零 FK、零触发器、零视图引用、除 PRIMARY 外无索引。
运行时告警判定权威一直是 ado_s8_watch_rule。

删除
- AdoS8AlertRule / S8AlertRuleService / AdoS8ConfigAlertRulesController
- Web S8AlertRulesPage.vue、FUNC-S8-014 登记项、SysMenu 种子条目
- Startup 的 CodeFirst 实体登记
- S8AlertRuleWriteRetiredTests(断言的是「Legacy 被拒绝」,而类型现已不存在,
  留着会让人误以为 Legacy 仍是一条需要防守的路径)
- S8WatchHitResult.AlertRuleId(自始至终没有任何写入点)
- 1.0.486.sql:DROP TABLE ado_s8_alert_rule + 清 SysMenu 与其 SysRoleMenu 授权行

evaluator 契约收窄
IS8RuleEvaluator.EvaluateAsync 及三个实现去掉 alertRules 形参。该假依赖除了误导
读者以为阈值来自那张表,还让每个 tick 多打一次全表查询。

新增 S8LegacySymbolsRemovedTests:用运行期反射按完全限定名断言类型**不存在**
(已删除的类型写不进 typeof,编译期引用无法表达这个断言),并锁住 evaluator
签名与全部实现类均无 alertRules 重载。

编号不回收、不重排:FUNC-S8-014 与菜单偏移 14 留空,重排会让既有文档与
SysMenu 行的 Id 全部错位。

⚠️ 不要与 ado_s8_alert_record 混淆——那是另一张表,有活跃消费方
(AidopKanbanController 看板查询,4 处),本批未触碰。

chore: bump version Web 2.4.378 / server 1.0.486
YY968XX 2 天之前
父節點
當前提交
c434e58209

+ 1 - 1
Web/package.json

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

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

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

+ 0 - 111
Web/src/views/aidop/s8/config/S8AlertRulesPage.vue

@@ -1,111 +0,0 @@
-<script setup lang="ts" name="aidopS8AlertRulesConfig">
-/**
- * S8-STEP6D-CFG-ALERT-CLOSURE-1:报警规则页收口为「历史兼容 · 只读」。
- *
- * 产品模型裁决 = LEGACY_SHELL。依据(运行时实证,非推断):
- *   1. 三个 evaluator(TIMEOUT / SHORTAGE / OUT_OF_RANGE)的 EvaluateAsync 都接收
- *      IReadOnlyList<AdoS8AlertRule> alertRules 形参,但该标识符在每个文件中**只出现一次**
- *      (即形参声明本身),方法体内读取次数 = 0。
- *   2. 生产调度 tick(S8WatchSchedulerJob → RunDispatchTickAsync → RunSingleRuleAsync)
- *      在 S8WatchSchedulerService.cs:1294 为每条租约规则查一次 ado_s8_alert_rule,
- *      转手传给 evaluator 后被丢弃 —— 即 NO-OP READ。
- *   3. 唯一真正读取 alert_rule 字段的 LoadExecutionRulesAsync 只能经
- *      AdoS8WatchDebugController 抵达,而该 controller 在 WatchScheduler:DebugEndpointEnabled
- *      为 false(生产默认)时全部返回 404。
- *   4. 阈值/严重度/参数/表达式的真实来源已于 2026-04-27 迁入
- *      ado_s8_watch_rule 的 params_json / severity / expression / rule_type。
- *
- * 因此本页**不得**继续提供完整可编辑 CRUD —— 那会让用户保存一份永远不生效的配置。
- * 保留正式 route 与页面(CFG_ALERT 属正式 20 页,不做删除),改为只读说明态:
- * 如实告知运行时判定由监控规则接管,并只读展示历史遗留行。
- *
- * 未做(刻意):不接线回 evaluator(会改变真实异常生成行为,须先有产品拍板);
- * 不修 CreateAsync 的自增回填(写路径已关闭,不为凑分修死路径);不 Seed;不动 alert_channel / alert_model。
- */
-import { onMounted, ref } from 'vue';
-import { ElMessage } from 'element-plus';
-import AidopDemoShell from '../../components/AidopDemoShell.vue';
-import { s8ConfigApi } from '../api/s8ConfigApi';
-
-const ENDPOINT = '/api/aidop/s8/config/alert-rules';
-
-const loading = ref(false);
-const rows = ref<Record<string, any>[]>([]);
-
-const severityLabel = (val: string) => (val === 'SERIOUS' ? '严重' : val === 'FOLLOW' ? '关注' : val || '-');
-
-async function loadData() {
-	loading.value = true;
-	try {
-		const data = (await s8ConfigApi.list(ENDPOINT)) as Record<string, any>[];
-		rows.value = Array.isArray(data) ? data : [];
-	} catch (e: any) {
-		ElMessage.error(e?.message || '加载报警规则失败');
-		rows.value = [];
-	} finally {
-		loading.value = false;
-	}
-}
-
-onMounted(() => {
-	void loadData();
-});
-</script>
-
-<template>
-	<AidopDemoShell title="报警规则配置" subtitle="S8 / 配置 / 报警规则">
-		<el-alert type="warning" :closable="false" show-icon class="legacy-notice">
-			<template #title>本页为历史兼容配置,不参与当前运行时判定</template>
-			<div class="legacy-notice__body">
-				当前告警判定由<strong>「监控规则」</strong>(Watch Rule)管理,阈值、严重度与触发参数均在该页维护。
-				本页的报警规则是早期版本的遗留配置,运行时不会读取,因此已收敛为只读展示,不提供新增、编辑与删除。
-			</div>
-		</el-alert>
-
-		<div class="toolbar">
-			<el-button :loading="loading" @click="loadData">刷新</el-button>
-		</div>
-
-		<el-table v-loading="loading" :data="rows" border stripe>
-			<el-table-column prop="ruleCode" label="规则编码" width="180" />
-			<el-table-column prop="sceneCode" label="场景编码" width="160" />
-			<el-table-column label="严重度" width="110">
-				<template #default="{ row }">{{ severityLabel(row.severity) }}</template>
-			</el-table-column>
-			<el-table-column prop="triggerCondition" label="触发条件" />
-			<el-table-column prop="thresholdVal" label="阈值" width="140" />
-			<el-table-column label="状态" width="140">
-				<template #default>
-					<el-tag type="info" size="small">历史配置 · 未生效</el-tag>
-				</template>
-			</el-table-column>
-			<template #empty>
-				<div class="legacy-empty">本工厂没有历史报警规则。告警判定请前往「监控规则」页维护。</div>
-			</template>
-		</el-table>
-	</AidopDemoShell>
-</template>
-
-<style scoped>
-.legacy-notice {
-	margin-bottom: 16px;
-}
-
-.legacy-notice__body {
-	margin-top: 6px;
-	font-size: 13px;
-	line-height: 1.7;
-}
-
-.toolbar {
-	display: flex;
-	gap: 12px;
-	margin-bottom: 12px;
-}
-
-.legacy-empty {
-	padding: 12px 0;
-	font-size: 13px;
-	color: var(--el-text-color-secondary);
-}
-</style>

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.485</AssemblyVersion>
-    <FileVersion>1.0.485</FileVersion>
-    <Version>1.0.485</Version>
+    <AssemblyVersion>1.0.486</AssemblyVersion>
+    <FileVersion>1.0.486</FileVersion>
+    <Version>1.0.486</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -676,6 +676,9 @@
     <None Update="UpdateScripts\1.0.485.sql">
     <None Update="UpdateScripts\1.0.485.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.486.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>

+ 31 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.486.sql

@@ -0,0 +1,31 @@
+-- S8-STANDARD-DATASET-HARD-CUTOVER-1 · 步骤 1:物理删除「报警规则」整条功能。
+--
+-- 裁决:S8 的运行时告警判定权威是 ado_s8_watch_rule,从来不是 ado_s8_alert_rule。
+-- 取证(三条,互相独立):
+--   1. 三个 evaluator 的 IReadOnlyList<AdoS8AlertRule> alertRules 形参,方法体读取次数 = 0;
+--   2. 调度器每 tick 查一次该表后转手丢弃(NO-OP READ),本批已连同形参一并删除;
+--   3. 全表 1 行(G01_TEST_ALERT / scene 'S2S6_PRODUCTION' 这一已废弃的复合场景码),
+--      零 FK、零触发器、零视图引用、除 PRIMARY 外无索引。
+--
+-- 上一阶段(S8-LEGACY-RUNTIME-RETIREMENT-1)只做到「写入口 410 + 入口下线」。
+-- 本阶段是 Hard Cutover:不保留只读 API、不保留表、不保留 410 兼容层。
+--
+-- ⚠️ 不要与 ado_s8_alert_record 混淆 —— 那是另一张表,有活跃消费方
+--    (AidopKanbanController 的看板查询,4 处)。本脚本不碰它。
+--
+-- 幂等:IF EXISTS + 带条件 DELETE,重复执行无副作用。
+
+-- ── 1. 删除配置入口菜单 ─────────────────────────────────────────────────────
+-- 上一版(1.0.484)把它置为 Status=2 停用;功能既已物理删除,条目本身不再有意义。
+-- 先清 SysRoleMenu 授权行,避免留下指向不存在菜单的悬挂授权。
+DELETE rm FROM SysRoleMenu rm
+  JOIN SysMenu m ON m.Id = rm.MenuId
+ WHERE m.Name = 'aidopS8AlertRulesConfig'
+   AND m.Path = '/aidop/s8/config/alert-rules';
+
+DELETE FROM SysMenu
+ WHERE Name = 'aidopS8AlertRulesConfig'
+   AND Path = '/aidop/s8/config/alert-rules';
+
+-- ── 2. 删除表 ───────────────────────────────────────────────────────────────
+DROP TABLE IF EXISTS ado_s8_alert_rule;

+ 0 - 116
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8AlertRuleWriteRetiredTests.cs

@@ -1,116 +0,0 @@
-using System.Runtime.CompilerServices;
-using Admin.NET.Plugin.AiDOP.Controllers.S8;
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-using Admin.NET.Plugin.AiDOP.Infrastructure;
-using Admin.NET.Plugin.AiDOP.Service.S8;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using Xunit;
-
-namespace Admin.NET.Plugin.AiDOP.Tests.S8;
-
-/// <summary>
-/// S8-STEP6D-CFG-ALERT-LEGACY-WRITE-GUARD-CERT-1:钉住 CFG_ALERT 的
-/// 「历史兼容只读 + 写入口退役」契约。
-///
-/// <para><b>不触任何数据库。</b> 这不是靠 mock 硬凑出来的——写方法在**任何 DB 访问之前**
-/// 就抛出,所以根本不需要可用的仓储。用 <see cref="RuntimeHelpers.GetUninitializedObject"/>
-/// 绕开构造函数(其参数 <c>SqlSugarRepository&lt;T&gt;</c> 的无参构造会触发 Furion.App
-/// 静态初始化,在无宿主的 xunit 进程内必抛 TypeInitializationException)。
-/// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 而失败——
-/// 这正是我们想要的信号:**守卫必须前置**。</para>
-/// </summary>
-public class S8AlertRuleWriteRetiredTests
-{
-    private static S8AlertRuleService Svc() =>
-        (S8AlertRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8AlertRuleService));
-
-    private static readonly S8TrustedScope Scope = new(838257186181189L, 838257186320453L);
-
-    private static AdoS8ConfigAlertRulesController Controller() => new(Svc(), null!);
-
-    private static void AssertGone(IActionResult result)
-    {
-        var objectResult = Assert.IsType<ObjectResult>(result);
-        Assert.Equal(StatusCodes.Status410Gone, objectResult.StatusCode);
-        var message = objectResult.Value!.GetType().GetProperty("message")!.GetValue(objectResult.Value) as string;
-        Assert.Equal(S8WriteRetiredException.UserMessage, message);
-    }
-
-    [Fact]
-    public void CreateAsync_IsRetired()
-    {
-        var ex = Assert.Throws<S8WriteRetiredException>(
-            () => { _ = Svc().CreateAsync(new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" }, Scope); });
-        Assert.Equal(S8WriteRetiredException.UserMessage, ex.Message);
-    }
-
-    [Fact]
-    public void UpdateAsync_IsRetired()
-    {
-        Assert.Throws<S8WriteRetiredException>(
-            () => { _ = Svc().UpdateAsync(1L, new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" }, Scope); });
-    }
-
-    [Fact]
-    public void DeleteAsync_IsRetired()
-    {
-        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(1L, Scope); });
-    }
-
-    [Fact]
-    public async Task Controller_Create_Returns410_WithoutScopeOrRepositoryAccess()
-    {
-        AssertGone(await Controller().CreateAsync(new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" }));
-    }
-
-    [Theory]
-    [InlineData(1L)]
-    [InlineData(999999999L)]
-    public async Task Controller_UpdateAndDelete_Return410_RegardlessOfId(long id)
-    {
-        var controller = Controller();
-        AssertGone(await controller.UpdateAsync(id, new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" }));
-        AssertGone(await controller.DeleteAsync(id));
-    }
-
-    /// <summary>越权 / 不存在的 Id 同样返回「已退役」,不得因 Id 不同而走回 404 分支。</summary>
-    [Theory]
-    [InlineData(0L)]
-    [InlineData(1L)]            // Demo 租户实际存在的 G01_TEST_ALERT
-    [InlineData(999999999L)]    // 不存在
-    public void Writes_AreRetired_RegardlessOfId(long id)
-    {
-        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(id, Scope); });
-        Assert.Throws<S8WriteRetiredException>(
-            () => { _ = Svc().UpdateAsync(id, new AdoS8AlertRule { RuleCode = "X", SceneCode = "S1" }, Scope); });
-    }
-
-    /// <summary>读路径必须保留:历史兼容数据仍要能查。</summary>
-    [Fact]
-    public void ReadPath_IsPreserved()
-    {
-        Assert.NotNull(typeof(S8AlertRuleService).GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
-    }
-
-    /// <summary>
-    /// 继承关系是防御性设计:只捕获 S8BizException 的旧调用方会安全降级为 400,而不是 500。
-    /// </summary>
-    [Fact]
-    public void RetiredException_DerivesFromBizException()
-    {
-        Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8WriteRetiredException)));
-        Assert.False(typeof(S8NotFoundException).IsAssignableFrom(typeof(S8WriteRetiredException)));
-    }
-
-    /// <summary>用户文案必须同时说明「只读」与「当前权威是监控规则」,且不含内部实现细节。</summary>
-    [Fact]
-    public void UserMessage_StatesReadOnlyAndWatchRuleAuthority()
-    {
-        var m = S8WriteRetiredException.UserMessage;
-        Assert.Contains("只读", m);
-        Assert.Contains("监控规则", m);
-        foreach (var leak in new[] { "Exception", "SqlSugar", "ado_s8_", "Service", "null" })
-            Assert.DoesNotContain(leak, m, StringComparison.OrdinalIgnoreCase);
-    }
-}

+ 86 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8LegacySymbolsRemovedTests.cs

@@ -0,0 +1,86 @@
+using System.Reflection;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-STANDARD-DATASET-HARD-CUTOVER-1:Legacy 符号**不存在**契约。
+///
+/// 与上一阶段的区别必须说清楚,否则这个文件会被误当成重复覆盖:
+///   · 退役阶段断言的是「Legacy 被拒绝」——类型还在,只是调用会抛 410 / legacy_sql_retired;
+///   · 本阶段断言的是「Legacy 根本不存在」——类型、Provider、参数、表全部消失。
+/// 前者的测试已随实现一并删除;保留它们只会让人以为 Legacy 仍是一条需要防守的路径。
+///
+/// 用**运行期反射按名字查找**而非编译期引用:已删除的类型写不进 typeof(),
+/// 编译期引用等于永远无法表达「它不存在」这个断言。
+/// 名字用完全限定名,避免同名新类型意外让断言变绿。
+/// </summary>
+public class S8LegacySymbolsRemovedTests
+{
+    private static readonly Assembly PluginAssembly = typeof(AdoS8WatchRule).Assembly;
+
+    private static Type? Find(string fullName) => PluginAssembly.GetType(fullName, throwOnError: false);
+
+    /// <summary>
+    /// 全量 Legacy 类型清单。任何一条复活都会让本用例红——
+    /// 这正是目的:Hard Cutover 之后不接受「先加回来再说」。
+    /// </summary>
+    public static TheoryData<string> RemovedTypes() => new()
+    {
+        // 报警规则(历史只读壳,Runtime Authority 一直是 AdoS8WatchRule)
+        "Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8AlertRule",
+        "Admin.NET.Plugin.AiDOP.Service.S8.S8AlertRuleService",
+        "Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigAlertRulesController",
+    };
+
+    [Theory]
+    [MemberData(nameof(RemovedTypes))]
+    public void LegacyType_NoLongerExists(string fullName)
+    {
+        Assert.Null(Find(fullName));
+    }
+
+    /// <summary>
+    /// T10:evaluator 契约不得再出现 alertRules 死参数。
+    ///
+    /// 该形参存在期间,三个实现的方法体读取次数均为 0,调度器查库后转手丢弃——
+    /// 一个纯粹的假依赖,却让每个 tick 多打一次全表查询,也误导读者以为告警阈值来自那张表。
+    /// </summary>
+    [Fact]
+    public void EvaluatorContract_HasNoAlertRulesParameter()
+    {
+        var evaluate = typeof(IS8RuleEvaluator).GetMethod(nameof(IS8RuleEvaluator.EvaluateAsync))!;
+        var names = evaluate.GetParameters().Select(p => p.Name).ToArray();
+
+        Assert.DoesNotContain("alertRules", names);
+        Assert.Equal(new[] { "tenantId", "factoryId", "rule", "cancellationToken" }, names);
+    }
+
+    /// <summary>
+    /// 所有实现必须与接口同步收窄。只查接口不够——
+    /// 实现类若保留旧重载,调用方仍可能绑到它。
+    /// </summary>
+    [Fact]
+    public void AllEvaluatorImplementations_HaveNoAlertRulesParameter()
+    {
+        var implementations = PluginAssembly.GetTypes()
+            .Where(t => t is { IsClass: true, IsAbstract: false } && typeof(IS8RuleEvaluator).IsAssignableFrom(t))
+            .ToArray();
+
+        // 防止「一个实现都没找到」时本用例空转通过。
+        Assert.NotEmpty(implementations);
+
+        foreach (var impl in implementations)
+        {
+            var overloads = impl.GetMethods(BindingFlags.Public | BindingFlags.Instance)
+                .Where(m => m.Name == nameof(IS8RuleEvaluator.EvaluateAsync))
+                .ToArray();
+
+            Assert.NotEmpty(overloads);
+            foreach (var m in overloads)
+                Assert.DoesNotContain(m.GetParameters(), p => p.Name == "alertRules");
+        }
+    }
+}

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

@@ -71,9 +71,11 @@ public class S8TenantIsolationContractTests
     /// 缺参数即编译期失败,杜绝「按裸 Id 改 / 删 / 重归属」回归。
     /// 缺参数即编译期失败,杜绝「按裸 Id 改 / 删 / 重归属」回归。
     /// </summary>
     /// </summary>
     [Theory]
     [Theory]
+    // S8-STANDARD-DATASET-HARD-CUTOVER-1:S8AlertRuleService / AdoS8AlertRule 已物理删除,
+    // 其条目随之移除——类型都不存在了,「写入口必须带作用域」自然无从违反。
+    // 该功能的消失本身由 S8LegacySymbolsRemovedTests 断言。
     [InlineData(typeof(S8SceneConfigService), typeof(AdoS8SceneConfig))]
     [InlineData(typeof(S8SceneConfigService), typeof(AdoS8SceneConfig))]
     [InlineData(typeof(S8DataSourceService), typeof(AdoS8DataSource))]
     [InlineData(typeof(S8DataSourceService), typeof(AdoS8DataSource))]
-    [InlineData(typeof(S8AlertRuleService), typeof(AdoS8AlertRule))]
     [InlineData(typeof(S8NotificationLayerService), typeof(AdoS8NotificationLayer))]
     [InlineData(typeof(S8NotificationLayerService), typeof(AdoS8NotificationLayer))]
     [InlineData(typeof(S8RoleConfigService), typeof(AdoS8RolePermissionConfig))]
     [InlineData(typeof(S8RoleConfigService), typeof(AdoS8RolePermissionConfig))]
     [InlineData(typeof(S8DashboardCellConfigService), typeof(AdoS8DashboardCellConfig))]
     [InlineData(typeof(S8DashboardCellConfigService), typeof(AdoS8DashboardCellConfig))]

+ 0 - 82
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigAlertRulesController.cs

@@ -1,82 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
-using Admin.NET.Plugin.AiDOP.Const.S8;
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-using Admin.NET.Plugin.AiDOP.Infrastructure;
-using Admin.NET.Plugin.AiDOP.Service.S8;
-
-namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
-
-[ApiController]
-[Route("api/aidop/s8/config/alert-rules")]
-[NonUnify]
-public class AdoS8ConfigAlertRulesController : ControllerBase
-{
-    // 写能力已永久退役:不得为了构造一个不会被使用的 scope 而读取组织库。
-    // Service 写方法必须在读取该占位值或访问任何仓储前抛 S8WriteRetiredException。
-    private static readonly S8TrustedScope RetiredWriteScope = new(0, 0);
-
-    private readonly S8AlertRuleService _svc;
-    private readonly S8TrustedScopeResolver _scope;
-
-    public AdoS8ConfigAlertRulesController(S8AlertRuleService svc, S8TrustedScopeResolver scope)
-    {
-        _svc = svc;
-        _scope = scope;
-    }
-
-    [HttpGet]
-    [S8Permission(S8PermissionCatalog.ConfigRead)]
-    public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
-    {
-        // Compatibility-only. Security scope is resolved server-side.
-        _ = tenantId; _ = factoryId;
-        var scope = await _scope.ResolveAsync();
-        return Ok(await _svc.ListAsync(scope.TenantId, scope.FactoryId));
-    }
-
-    // ================================================================================
-    // S8-STEP6D-CFG-ALERT-LEGACY-WRITE-GUARD-CERT-1:POST / PUT / DELETE 已退役 → 410 Gone。
-    //
-    // 410 而非 400/403/404 是刻意选择:语义为「该能力曾经存在、现已永久退役」。
-    // 400 会暗示「改对入参还能存」,404 会暗示「换个 Id 还能存」,都与事实不符。
-    //
-    // ⚠️ catch 顺序:S8WriteRetiredException 必须排在 S8NotFoundException / S8BizException 之前,
-    //    否则会被基类分支先接住、降级成 404/400。
-    // 返回固定文案常量(不是 ex.ToString()/堆栈/类型名),避免泄漏内部实现。
-    // GET 保持不变,历史兼容数据仍可读。
-    // ================================================================================
-
-    private IActionResult Retired() =>
-        StatusCode(Microsoft.AspNetCore.Http.StatusCodes.Status410Gone,
-            new { message = S8WriteRetiredException.UserMessage });
-
-    [HttpPost]
-    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> CreateAsync([FromBody] AdoS8AlertRule body)
-    {
-        try { return Ok(await _svc.CreateAsync(body, RetiredWriteScope)); }
-        catch (S8WriteRetiredException) { return Retired(); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-
-    [HttpPut("{id:long}")]
-    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8AlertRule body)
-    {
-        try { return Ok(await _svc.UpdateAsync(id, body, RetiredWriteScope)); }
-        catch (S8WriteRetiredException) { return Retired(); }
-        catch (S8NotFoundException) { return NotFound(); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-
-    [HttpDelete("{id:long}")]
-    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
-    public async Task<IActionResult> DeleteAsync(long id)
-    {
-        try { await _svc.DeleteAsync(id, RetiredWriteScope); }
-        catch (S8WriteRetiredException) { return Retired(); }
-        catch (S8NotFoundException) { return NotFound(); }
-        return Ok();
-    }
-}

+ 0 - 39
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8AlertRule.cs

@@ -1,39 +0,0 @@
-namespace Admin.NET.Plugin.AiDOP.Entity.S8;
-
-[SugarTable("ado_s8_alert_rule", "S8 报警规则")]
-public class AdoS8AlertRule
-{
-    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
-    public long Id { get; set; }
-
-    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
-    public long TenantId { get; set; }
-
-    [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
-    public long FactoryId { get; set; }
-
-    [SugarColumn(ColumnName = "rule_code", Length = 64)]
-    public string RuleCode { get; set; } = string.Empty;
-
-    [SugarColumn(ColumnName = "scene_code", Length = 64)]
-    public string SceneCode { get; set; } = string.Empty;
-
-    /// <summary>S8 业务枚举 FOLLOW(关注)/ SERIOUS(严重)。</summary>
-    [SugarColumn(ColumnName = "severity", Length = 32)]
-    public string Severity { get; set; } = "FOLLOW";
-
-    [SugarColumn(ColumnName = "trigger_condition", Length = 512, IsNullable = true)]
-    public string? TriggerCondition { get; set; }
-
-    [SugarColumn(ColumnName = "threshold_val", Length = 128, IsNullable = true)]
-    public string? ThresholdVal { get; set; }
-
-    [SugarColumn(ColumnName = "time_window", Length = 64, IsNullable = true)]
-    public string? TimeWindow { get; set; }
-
-    [SugarColumn(ColumnName = "created_at")]
-    public DateTime CreatedAt { get; set; } = DateTime.Now;
-
-    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
-    public DateTime? UpdatedAt { get; set; }
-}

+ 4 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -1251,17 +1251,12 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             IsHide = true,
             IsHide = true,
             Remark = "S8 任务详情"
             Remark = "S8 任务详情"
         };
         };
-        // S8-LEGACY-RUNTIME-RETIREMENT-1:已退役的配置页(按 Name 匹配,不按 Id/顺序,
-        // 避免将来插入新条目导致偏移错位)。
-        //
-        //   · 数据源 —— S8 已确立为数据中台消费层,物理连接治理归数据平台;后端写入口同批退役。
-        //   · 报警规则 —— 运行时权威是 ado_s8_watch_rule(监控规则页);本表写入口早已 410、
-        //     evaluator 对其形参读取次数为 0。
+        // S8-STANDARD-DATASET-HARD-CUTOVER-1:报警规则页已物理删除,不再需要「停用」名单。
+        // 数据源页在同批次的下一步一并物理删除,届时本集合会清空。
         //
         //
         // ⚠️ 不得把 aidopS8WatchRuleConfig(监控规则)加进来 —— 那是运行时权威本身。
         // ⚠️ 不得把 aidopS8WatchRuleConfig(监控规则)加进来 —— 那是运行时权威本身。
         var RetiredConfigMenuNames = new HashSet<string>(StringComparer.Ordinal)
         var RetiredConfigMenuNames = new HashSet<string>(StringComparer.Ordinal)
         {
         {
-            "aidopS8AlertRulesConfig",
             "aidopS8DataSourceConfig",
             "aidopS8DataSourceConfig",
         };
         };
 
 
@@ -1272,10 +1267,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             // S8-CONFIG-AUTHORITY-FIX-1:该路由只承载操作员账号绑定;S8 鉴权走 SysMenu/SysRoleMenu,
             // S8-CONFIG-AUTHORITY-FIX-1:该路由只承载操作员账号绑定;S8 鉴权走 SysMenu/SysRoleMenu,
             // 与本页无关。Name / Path / Component 保持不变(避免动态路由与既有授权失效),仅纠正 Title。
             // 与本页无关。Name / Path / Component 保持不变(避免动态路由与既有授权失效),仅纠正 Title。
             (13, "/aidop/s8/config/roles", "aidopS8RolePermissionConfig", "操作员账号绑定", "/aidop/s8/config/S8RolePermissionConfigPage"),
             (13, "/aidop/s8/config/roles", "aidopS8RolePermissionConfig", "操作员账号绑定", "/aidop/s8/config/S8RolePermissionConfigPage"),
-            // S8-LEGACY-RUNTIME-RETIREMENT-1:以下两条已停用(见下方 RetiredConfigMenuNames)。
-            // 条目本身保留:删掉会让既有 SysRoleMenu 授权行成为悬挂引用,
-            // 且 Name / Path / Component 是历史数据的定位锚点。
-            (14, "/aidop/s8/config/alert-rules", "aidopS8AlertRulesConfig", "报警规则配置", "/aidop/s8/config/S8AlertRulesPage"),
+            // S8-STANDARD-DATASET-HARD-CUTOVER-1:偏移 14(报警规则配置)已随功能物理删除。
+            // 偏移号不回收、不重排——重排会让既有 SysMenu 行的 Id 与新种子对不上。
             (15, "/aidop/s8/config/data-sources", "aidopS8DataSourceConfig", "数据源配置", "/aidop/s8/config/S8DataSourceConfigPage"),
             (15, "/aidop/s8/config/data-sources", "aidopS8DataSourceConfig", "数据源配置", "/aidop/s8/config/S8DataSourceConfigPage"),
             (16, "/aidop/s8/config/watch-rules", "aidopS8WatchRuleConfig", "监视规则配置", "/aidop/s8/config/S8WatchRuleConfigPage"),
             (16, "/aidop/s8/config/watch-rules", "aidopS8WatchRuleConfig", "监视规则配置", "/aidop/s8/config/S8WatchRuleConfigPage"),
             (17, "/aidop/s8/config/exception-types", "aidopS8ExceptionTypeConfig", "异常类型配置", "/aidop/s8/config/S8ExceptionTypeConfigPage"),
             (17, "/aidop/s8/config/exception-types", "aidopS8ExceptionTypeConfig", "异常类型配置", "/aidop/s8/config/S8ExceptionTypeConfigPage"),

+ 0 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/IS8RuleEvaluator.cs

@@ -17,7 +17,6 @@ public interface IS8RuleEvaluator
         long tenantId,
         long tenantId,
         long factoryId,
         long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
-        IReadOnlyList<AdoS8AlertRule> alertRules,
         CancellationToken cancellationToken = default);
         CancellationToken cancellationToken = default);
 }
 }
 
 

+ 0 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -48,7 +48,6 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         long tenantId,
         long tenantId,
         long factoryId,
         long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
-        IReadOnlyList<AdoS8AlertRule> alertRules,
         CancellationToken cancellationToken = default)
         CancellationToken cancellationToken = default)
     {
     {
         // R5 evaluator 失败语义保护:与 TIMEOUT/SHORTAGE 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
         // R5 evaluator 失败语义保护:与 TIMEOUT/SHORTAGE 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。

+ 0 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs

@@ -41,7 +41,6 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         long tenantId,
         long tenantId,
         long factoryId,
         long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
-        IReadOnlyList<AdoS8AlertRule> alertRules,
         CancellationToken cancellationToken = default)
         CancellationToken cancellationToken = default)
     {
     {
         // R5 evaluator 失败语义保护:与 TIMEOUT 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
         // R5 evaluator 失败语义保护:与 TIMEOUT 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。

+ 0 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -44,7 +44,6 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         long tenantId,
         long tenantId,
         long factoryId,
         long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
-        IReadOnlyList<AdoS8AlertRule> alertRules,
         CancellationToken cancellationToken = default)
         CancellationToken cancellationToken = default)
     {
     {
         // R5 evaluator 失败语义保护:所有"非命中判定"路径均改为抛出 S8RuleEvaluatorException,
         // R5 evaluator 失败语义保护:所有"非命中判定"路径均改为抛出 S8RuleEvaluatorException,

+ 0 - 48
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8AlertRuleService.cs

@@ -1,48 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Entity.S8;
-using Admin.NET.Plugin.AiDOP.Infrastructure;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8;
-
-public class S8AlertRuleService : ITransient
-{
-    private readonly SqlSugarRepository<AdoS8AlertRule> _rep;
-
-    public S8AlertRuleService(SqlSugarRepository<AdoS8AlertRule> rep) => _rep = rep;
-
-    public async Task<List<AdoS8AlertRule>> ListAsync(long tenantId, long factoryId) =>
-        await _rep.AsQueryable()
-            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
-            .ToListAsync();
-
-    // ================================================================================
-    // S8-STEP6D-CFG-ALERT-LEGACY-WRITE-GUARD-CERT-1:写入口已退役(LEGACY_SHELL 契约)。
-    //
-    // 产品裁决:ado_s8_alert_rule = 历史兼容只读数据;运行时告警判定权威 = ado_s8_watch_rule。
-    // 依据(运行时实证):三个 evaluator 的 EvaluateAsync 都接收 IReadOnlyList<AdoS8AlertRule>,
-    // 但该形参在每个文件中只出现一次(声明本身),方法体读取次数 = 0;生产 tick 在
-    // S8WatchSchedulerService.cs:1294 查一次后转手丢弃(NO-OP READ);唯一真正读取其字段的
-    // LoadExecutionRulesAsync 只能经 AdoS8WatchDebugController 抵达,而后者生产默认 404。
-    //
-    // 因此继续开放写入 = 让调用方保存一份**永远不会生效**的配置。仅在前端隐藏按钮不够:
-    // API 仍可直接写入。故在此服务层(写入的唯一收敛点)统一拒绝。
-    //
-    // ⚠️ 抛出发生在**任何 DB 访问之前**:不做存在性查询、不做 LoadScopedAsync。
-    //    这既保证零 DB 触碰,也保证 PUT/DELETE 对任意 id(含越权 id)一律返回 410 而非 404
-    //    —— 「这个能力没了」优先于「这条记录不属于你」,避免用越权探测反推他租户数据是否存在。
-    //
-    // 保留三个方法签名(而非删除)是**硬约束**:S8TenantIsolationContractTests
-    // 用反射断言带 S8TrustedScope 的写入口存在、且无 scope 的旧重载不存在。
-    //
-    // 读路径完全保留(ListAsync);调度器与 debug 链走 SqlSugarRepository<AdoS8AlertRule>
-    // 直连仓储、不经本服务,故不受影响。
-    // ================================================================================
-
-    public Task<AdoS8AlertRule> CreateAsync(AdoS8AlertRule body, S8TrustedScope scope) =>
-        throw new S8WriteRetiredException();
-
-    public Task<AdoS8AlertRule> UpdateAsync(long id, AdoS8AlertRule body, S8TrustedScope scope) =>
-        throw new S8WriteRetiredException();
-
-    public Task DeleteAsync(long id, S8TrustedScope scope) =>
-        throw new S8WriteRetiredException();
-}

+ 4 - 16
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -17,7 +17,6 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 public class S8WatchSchedulerService : ITransient
 public class S8WatchSchedulerService : ITransient
 {
 {
     private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
     private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
-    private readonly SqlSugarRepository<AdoS8AlertRule> _alertRuleRep;
     private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
     private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
     private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
     private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
     private readonly S8NotificationService _notificationService;
     private readonly S8NotificationService _notificationService;
@@ -58,7 +57,6 @@ public class S8WatchSchedulerService : ITransient
 
 
     public S8WatchSchedulerService(
     public S8WatchSchedulerService(
         SqlSugarRepository<AdoS8WatchRule> ruleRep,
         SqlSugarRepository<AdoS8WatchRule> ruleRep,
-        SqlSugarRepository<AdoS8AlertRule> alertRuleRep,
         SqlSugarRepository<AdoS8Exception> exceptionRep,
         SqlSugarRepository<AdoS8Exception> exceptionRep,
         SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
         SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
         S8NotificationService notificationService,
         S8NotificationService notificationService,
@@ -75,7 +73,6 @@ public class S8WatchSchedulerService : ITransient
         SqlSugarRepository<AdoS8RuleDetectionState> detectionStateRep)
         SqlSugarRepository<AdoS8RuleDetectionState> detectionStateRep)
     {
     {
         _ruleRep = ruleRep;
         _ruleRep = ruleRep;
-        _alertRuleRep = alertRuleRep;
         _exceptionRep = exceptionRep;
         _exceptionRep = exceptionRep;
         _exceptionTypeRep = exceptionTypeRep;
         _exceptionTypeRep = exceptionTypeRep;
         _notificationService = notificationService;
         _notificationService = notificationService;
@@ -185,10 +182,6 @@ public class S8WatchSchedulerService : ITransient
             .ToListAsync();
             .ToListAsync();
         if (rules.Count == 0) return aggregate;
         if (rules.Count == 0) return aggregate;
 
 
-        var alertRules = (await _alertRuleRep.AsQueryable()
-            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
-            .ToListAsync()).AsReadOnly();
-
         foreach (var rule in rules.OrderBy(x => x.Id))
         foreach (var rule in rules.OrderBy(x => x.Id))
         {
         {
             // S8-RUN-ONCE-LEASE-AWARENESS-1:debug run-once 不持 lease,但若该 rule 已被 Scheduler Job
             // S8-RUN-ONCE-LEASE-AWARENESS-1:debug run-once 不持 lease,但若该 rule 已被 Scheduler Job
@@ -211,7 +204,7 @@ public class S8WatchSchedulerService : ITransient
             S8RuleRunResult completion;
             S8RuleRunResult completion;
             try
             try
             {
             {
-                var ruleResults = await ProcessSingleRuleAsync(tenantId, factoryId, rule, ruleType, evaluator, alertRules, runId);
+                var ruleResults = await ProcessSingleRuleAsync(tenantId, factoryId, rule, ruleType, evaluator, runId);
                 aggregate.AddRange(ruleResults);
                 aggregate.AddRange(ruleResults);
                 completion = new S8RuleRunResult
                 completion = new S8RuleRunResult
                 {
                 {
@@ -889,11 +882,7 @@ public class S8WatchSchedulerService : ITransient
 
 
         try
         try
         {
         {
-            var alertRules = (await _alertRuleRep.AsQueryable()
-                .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
-                .ToListAsync()).AsReadOnly();
-
-            var results = await ProcessSingleRuleAsync(tenantId, factoryId, rule, ruleType, evaluator, alertRules, lease.RunId);
+            var results = await ProcessSingleRuleAsync(tenantId, factoryId, rule, ruleType, evaluator, lease.RunId);
             var stats = new S8RuleRunStats
             var stats = new S8RuleRunStats
             {
             {
                 Hits = results.Count,
                 Hits = results.Count,
@@ -921,13 +910,13 @@ public class S8WatchSchedulerService : ITransient
     /// </summary>
     /// </summary>
     private async Task<List<S8WatchCreationResult>> ProcessSingleRuleAsync(
     private async Task<List<S8WatchCreationResult>> ProcessSingleRuleAsync(
         long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
         long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
-        IS8RuleEvaluator evaluator, IReadOnlyList<AdoS8AlertRule> alertRules, string runId)
+        IS8RuleEvaluator evaluator, string runId)
     {
     {
         var results = new List<S8WatchCreationResult>();
         var results = new List<S8WatchCreationResult>();
         List<S8RuleHit> hits;
         List<S8RuleHit> hits;
         try
         try
         {
         {
-            hits = await evaluator.EvaluateAsync(tenantId, factoryId, rule, alertRules);
+            hits = await evaluator.EvaluateAsync(tenantId, factoryId, rule);
         }
         }
         catch (Exception ex)
         catch (Exception ex)
         {
         {
@@ -1431,7 +1420,6 @@ public sealed class S8WatchHitResult
 {
 {
     public long SourceRuleId { get; set; }
     public long SourceRuleId { get; set; }
     public string SourceRuleCode { get; set; } = string.Empty;
     public string SourceRuleCode { get; set; } = string.Empty;
-    public long AlertRuleId { get; set; }
     public long DataSourceId { get; set; }
     public long DataSourceId { get; set; }
     public string RelatedObjectCode { get; set; } = string.Empty;
     public string RelatedObjectCode { get; set; } = string.Empty;
     public decimal CurrentValue { get; set; }
     public decimal CurrentValue { get; set; }

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

@@ -176,8 +176,9 @@ public class Startup : AppStartup
                 typeof(AdoS8ExceptionTimeline),
                 typeof(AdoS8ExceptionTimeline),
                 typeof(AdoS8DecisionRecord),
                 typeof(AdoS8DecisionRecord),
                 typeof(AdoS8Evidence),
                 typeof(AdoS8Evidence),
+                // S8-STANDARD-DATASET-HARD-CUTOVER-1:AdoS8AlertRule 已物理删除
+                // (运行时权威一直是 AdoS8WatchRule;该表零 Runtime Consumer)。
                 typeof(AdoS8SceneConfig),
                 typeof(AdoS8SceneConfig),
-                typeof(AdoS8AlertRule),
                 typeof(AdoS8NotificationLayer),
                 typeof(AdoS8NotificationLayer),
                 typeof(AdoS8DataSource),
                 typeof(AdoS8DataSource),
                 typeof(AdoS8WatchRule),
                 typeof(AdoS8WatchRule),