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

fix(s8): retire legacy alert rule writes | Web 2.4.342 / server 1.0.427

YY968XX 1 месяц назад
Родитель
Сommit
8396825ceb

+ 1 - 1
Web/package.json

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

+ 102 - 41
Web/src/views/aidop/s8/config/S8AlertRulesPage.vue

@@ -1,50 +1,111 @@
 <script setup lang="ts" name="aidopS8AlertRulesConfig">
-import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+/**
+ * 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 columns = [
-	{ key: 'ruleCode', label: '规则编码', width: 160 },
-	{ key: 'sceneCode', label: '场景编码', width: 140 },
-	{ key: 'severity', label: '严重度', width: 120 },
-	{ key: 'triggerCondition', label: '触发条件' },
-	{ key: 'thresholdVal', label: '阈值', width: 140 },
-];
-
-const fields = [
-	{ key: 'ruleCode', label: '规则编码', type: 'input', required: true },
-	{ key: 'sceneCode', label: '场景编码', type: 'select', required: true, optionsKey: 'scenes' },
-	{ key: 'severity', label: '严重度', type: 'select', required: true, optionsKey: 'severities' },
-	{ key: 'triggerCondition', label: '触发条件', type: 'input', required: true },
-	{ key: 'thresholdVal', label: '阈值', type: 'input' },
-	// S8-CONFIG-CLEANUP-DEMO-1:ado_s8_alert_rule.time_window 当前未被业务消费(不要误伤
-	// ado_s8_dashboard_cell_config.time_window,那个在 S8DashboardCellDataService.TimeRange 大量消费)。
-	// 仅渲染层隐藏,PUT 全量提交时旧值原样回写。
-	{ key: 'timeWindow', label: '时间窗口', type: 'input', hidden: true },
-] as const;
-
-const buildDefault = () => ({
-	ruleCode: '',
-	sceneCode: '',
-	severity: 'FOLLOW',
-	triggerCondition: '',
-	thresholdVal: '',
-	timeWindow: '',
-});
+const ENDPOINT = '/api/aidop/s8/config/alert-rules';
+
+const loading = ref(false);
+const rows = ref<Record<string, any>[]>([]);
 
-async function loadOptions() {
-	const [scenes, severities] = await Promise.all([s8ConfigApi.scenes(), s8ConfigApi.severities()]);
-	return { scenes: scenes.map((item: any) => ({ label: item.sceneName, value: item.sceneCode })), severities };
+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>
-	<S8CrudConfigPage
-		title="报警规则配置"
-		subtitle="S8 / 配置 / 报警规则"
-		endpoint="/api/aidop/s8/config/alert-rules"
-		:columns="columns"
-		:fields="fields"
-		:build-default="buildDefault"
-		:load-options="loadOptions"
-	/>
+	<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>

+ 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.426</AssemblyVersion>
-    <FileVersion>1.0.426</FileVersion>
-    <Version>1.0.426</Version>
+    <AssemblyVersion>1.0.427</AssemblyVersion>
+    <FileVersion>1.0.427</FileVersion>
+    <Version>1.0.427</Version>
   </PropertyGroup>
 
   <ItemGroup>

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

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

+ 26 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigAlertRulesController.cs

@@ -9,6 +9,10 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 [NonUnify]
 public class AdoS8ConfigAlertRulesController : ControllerBase
 {
+    // 写能力已永久退役:不得为了构造一个不会被使用的 scope 而读取组织库。
+    // Service 写方法必须在读取该占位值或访问任何仓储前抛 S8WriteRetiredException。
+    private static readonly S8TrustedScope RetiredWriteScope = new(0, 0);
+
     private readonly S8AlertRuleService _svc;
     private readonly S8TrustedScopeResolver _scope;
 
@@ -27,10 +31,27 @@ public class AdoS8ConfigAlertRulesController : ControllerBase
         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]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8AlertRule body)
     {
-        try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
+        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 }); }
     }
@@ -38,7 +59,8 @@ public class AdoS8ConfigAlertRulesController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8AlertRule body)
     {
-        try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
+        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 }); }
     }
@@ -46,7 +68,8 @@ public class AdoS8ConfigAlertRulesController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        try { await _svc.DeleteAsync(id, await _scope.ResolveAsync()); }
+        try { await _svc.DeleteAsync(id, RetiredWriteScope); }
+        catch (S8WriteRetiredException) { return Retired(); }
         catch (S8NotFoundException) { return NotFound(); }
         return Ok();
     }

+ 31 - 47
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8AlertRuleService.cs

@@ -14,51 +14,35 @@ public class S8AlertRuleService : ITransient
             .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
             .ToListAsync();
 
-    // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
-    public async Task<AdoS8AlertRule> CreateAsync(AdoS8AlertRule body, S8TrustedScope scope)
-    {
-        if (string.IsNullOrWhiteSpace(body.RuleCode) || string.IsNullOrWhiteSpace(body.SceneCode))
-            throw new S8BizException("规则编码和场景编码必填");
-        body.TenantId = scope.TenantId;
-        body.FactoryId = scope.FactoryId;
-        var exists = await _rep.AsQueryable()
-            .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
-                           (x.RuleCode == body.RuleCode || (x.SceneCode == body.SceneCode && x.TriggerCondition == body.TriggerCondition)));
-        if (exists) throw new S8BizException("相同场景下规则编码或触发条件重复");
-        body.Id = 0;
-        body.CreatedAt = DateTime.Now;
-        await _rep.InsertAsync(body);
-        return body;
-    }
-
-    // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。
-    public async Task<AdoS8AlertRule> UpdateAsync(long id, AdoS8AlertRule body, S8TrustedScope scope)
-    {
-        var e = await LoadScopedAsync(id, scope);
-        if (string.IsNullOrWhiteSpace(body.RuleCode) || string.IsNullOrWhiteSpace(body.SceneCode))
-            throw new S8BizException("规则编码和场景编码必填");
-        var exists = await _rep.AsQueryable()
-            .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId && x.FactoryId == e.FactoryId &&
-                           (x.RuleCode == body.RuleCode || (x.SceneCode == body.SceneCode && x.TriggerCondition == body.TriggerCondition)));
-        if (exists) throw new S8BizException("相同场景下规则编码或触发条件重复");
-        body.Id = id;
-        body.TenantId = e.TenantId;
-        body.FactoryId = e.FactoryId;
-        body.CreatedAt = e.CreatedAt;
-        body.UpdatedAt = DateTime.Now;
-        await _rep.UpdateAsync(body);
-        return body;
-    }
-
-    // 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);
-    }
-
-    private async Task<AdoS8AlertRule> 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-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();
 }

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

@@ -16,3 +16,24 @@ public sealed class S8NotFoundException : S8BizException
 {
     public S8NotFoundException(string message = "记录不存在") : base(message) { }
 }
+
+/// <summary>
+/// S8-STEP6D-CFG-ALERT-LEGACY-WRITE-GUARD-CERT-1:该配置对象已转为历史兼容只读,写入口已退役。
+/// 由 Controller 捕获后返回 <b>410 Gone</b>——语义是「该能力曾经存在、现已永久退役」,
+/// 区别于 400(入参不合法,改对了还能存)与 404(找不到,换个 Id 还能存)。
+///
+/// 继承 <see cref="S8BizException"/>,与 <see cref="S8NotFoundException"/> 同理:
+/// 使既有只捕获 S8BizException 的调用方安全降级为 400,而不是 500。
+/// ⚠️ Controller 的 catch 顺序必须把本类型排在 S8BizException 之前,否则会被基类先吞掉降级成 400。
+///
+/// <see cref="UserMessage"/> 是**固定文案**,供 Controller 直接返回;
+/// 不要把 ex.Message 以外的内部信息(异常类型名 / 堆栈 / SQL)暴露给前端。
+/// </summary>
+public sealed class S8WriteRetiredException : S8BizException
+{
+    /// <summary>面向用户的统一文案:说明只读原因 + 指出当前真正的运行时权威。</summary>
+    public const string UserMessage =
+        "告警规则已转为历史兼容只读配置,当前运行时告警判定由监控规则管理。";
+
+    public S8WriteRetiredException() : base(UserMessage) { }
+}