Ver Fonte

refactor(s8): 下线数据源与报警规则配置入口,并退役数据源写能力

S8 已确立为数据中台消费层:
  Data Platform → Governed Dataset → S8 Provider → Canonical Contract → Watch Rule
S8 不再承担 ETL、源系统连接、刷新任务、数据质量与独立数据源接入治理。

数据源(TASK A/B)
- 配置中心卡片下线;SysMenu 置 Status=Disable(1.0.484.sql 幂等同步既有库)。
- 写入口全部退役:Create / Update / Delete / Test 一律抛 S8WriteRetiredException,
  Controller 返回 410 Gone。仅隐藏 UI 不够——API 仍可直接调用,而 endpoint 列
  存的是含凭据的真实连接串。
- Test 一并退役:它虽只写 last_check_*,却会用库中 endpoint 真实建立外部连接,
  正是本批要终止的能力。随之移除 S8SqlSugarScopeFactory 依赖,使「S8 不再发起外连」
  成为结构性事实而非约定。
- GET 保留:存量 Legacy 规则仍绑着这些行,排障与历史核对需要能查(endpoint 仍脱敏)。

报警规则(TASK C)
- 配置中心卡片下线;SysMenu 同批停用。
- 运行时告警判定权威是 ado_s8_watch_rule。ado_s8_alert_rule 写入口早已 410;
  三个 evaluator 虽接收 alertRules 形参但方法体读取次数为 0,调度器查出后即丢弃。
  页面自述「历史兼容只读」与代码事实一致,继续暴露只会让用户误以为能在此配报警。
- 后端 Service / Controller GET / 表全部保留,物理清理留待后续独立批次。

菜单用 Status 而非 IsHide:这两页本就 IsHide=1(只能从配置中心卡片进入),
再隐藏是空操作;Status=Disable 才会让动态路由不再注册,手输 URL 也进不去。
不 DELETE 菜单行,避免既有 SysRoleMenu 授权成为悬挂引用。

测试
- 新增 S8DataSourceWriteRetiredTests:四个写入口 410、越权 id 不回落 404、
  读路径保留、服务不再持有连接工厂、文案不泄漏连接串。
- S8TenantIsolationContractTests 中「回填掩码不覆盖库中密码」用例改为断言
  写路径已不存在——原保障被更强的保障取代(不是写的时候小心,而是根本不能写)。

不做:不删 ado_s8_data_source / ado_s8_alert_rule 两表,不删 S8DataSourceRowLoader /
S8LegacySqlDataProvider / S8AlertRuleService,不动监控规则(运行时权威)。

chore: bump version Web 2.4.376 / server 1.0.484
YY968XX há 2 dias atrás
pai
commit
6111e56efe

+ 1 - 1
Web/package.json

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

+ 20 - 10
Web/src/views/aidop/s8/config/S8ConfigHubPage.vue

@@ -16,16 +16,28 @@ import { useRouter } from 'vue-router';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 
 const router = useRouter();
-// 配置中心入口 = 正式的 9 个 S8 配置页,与产品合同的 20 页清单一致
+// 配置中心入口 = 租户真正需要维护的 7 个 S8 配置页
 //
-// S8-FINAL-BLOCKERS-FIX-AND-TARGETED-CERT-1:恢复通知分层 / 报警规则 / 数据源三张卡片。
-// 此前 S8-CONFIG-HUB-DEMO-ENTRY-CLEANUP-REVISION-1 曾以「演示阶段」为由注释掉这三个入口,
-// 该决定现已 **SUPERSEDED**:三页均已完成真实 Runtime Certification,
-// 继续隐藏等于要求用户手输 URL 才能到达正式功能页。
+// S8-LEGACY-RUNTIME-RETIREMENT-1:下线「数据源」与「报警规则」两张卡片。
 //
-// ⚠️ 当时的隐藏理由「通知分层与 ApprovalFlow 通知重叠、整表未被业务消费」**已被实证推翻**:
-//    ado_s8_notification_layer 的 14 条 GLOBAL 生效层确由 S8NotificationLayerResolver
-//    在运行时消费(见 CFG_NOTIFY Runtime Certification)。该论断不得再作为产品行为依据。
+//   · 数据源 —— S8 已确立为数据中台**消费层**:
+//       Data Platform → Governed Dataset → S8 Provider → Canonical Contract → Rule
+//     物理连接(连接串 / 主机 / 凭据)归数据平台治理,不是租户 S8 管理员的职责。
+//     后端写入口同批退役(410),仅保留 GET 供存量 Legacy 规则排障。
+//
+//   · 报警规则 —— 运行时权威是 ado_s8_watch_rule(即「监控规则」页)。
+//     ado_s8_alert_rule 的写入口早已 410,三个 evaluator 虽接收 alertRules 形参但读取次数为 0,
+//     调度器查出后即丢弃。页面此前自述「历史兼容只读」,与代码事实一致 —— 既然改不了也不生效,
+//     继续暴露只会让用户以为这里能配报警。
+//
+// ⚠️ 本次只下线**入口**,不删表、不删后端:两张表与其 Service / GET 全部保留,
+//    物理清理留待后续独立批次。且**不得**顺手删「监控规则」—— 那才是运行时权威。
+//
+// 历史背景(勿重复推翻):S8-FINAL-BLOCKERS-FIX-AND-TARGETED-CERT-1 曾恢复通知分层 /
+// 报警规则 / 数据源三张卡片,理由是三页均已完成 Runtime Certification。
+// 其中**通知分层的论证依然成立**(ado_s8_notification_layer 的 14 条 GLOBAL 生效层确由
+// S8NotificationLayerResolver 在运行时消费),故保留;
+// 另两张卡片本次是按新架构裁决下线,与当时的「演示阶段」理由无关,不构成对该结论的反复。
 const cards = [
 	{ path: '/aidop/s8/config/scenes', title: '场景基础配置', desc: '场景编码、启用、排序' },
 	{ path: '/aidop/s8/config/exception-types', title: '异常类型', desc: '类型编码、SLA、责任角色' },
@@ -37,9 +49,7 @@ const cards = [
 	// 旧版角色权限 Tab 已在 S8-CONFIG-CLEANUP-DEMO-1 隐藏;S8 正式鉴权走 SysMenu/SysRoleMenu,
 	// 与本页无关。标题保留「角色权限」会让用户以为在这里能改权限,故去掉。
 	{ path: '/aidop/s8/config/roles', title: '操作员账号绑定', desc: '操作员与员工的绑定维护' },
-	{ path: '/aidop/s8/config/data-sources', title: '数据源', desc: '连接与启用' },
 	{ path: '/aidop/s8/config/notifications', title: '通知分层', desc: '场景 + 严重度 + 层级 + 推送渠道' },
-	{ path: '/aidop/s8/config/alert-rules', title: '报警规则', desc: '历史兼容只读,运行时判定见监控规则' },
 ];
 
 function go(path: string) {

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

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

+ 41 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.484.sql

@@ -0,0 +1,41 @@
+-- S8-LEGACY-RUNTIME-RETIREMENT-1 · TASK A/C:下线「数据源」与「报警规则」两个配置入口。
+--
+-- 背景(产品裁决):S8 已确立为数据中台**消费层**
+--     Data Platform → Governed Dataset → S8 Provider → Canonical Contract → Watch Rule
+--   · 数据源     —— 物理连接(连接串 / 主机 / 凭据)归数据平台治理,不由租户 S8 管理员维护。
+--                   后端写入口同批退役(410 Gone),仅保留 GET 供存量 Legacy 规则排障。
+--   · 报警规则   —— 运行时告警判定权威是 ado_s8_watch_rule(即「监控规则」页)。
+--                   ado_s8_alert_rule 的写入口早已 410;三个 evaluator 虽接收
+--                   IReadOnlyList<AdoS8AlertRule> 形参,但方法体读取次数为 0;
+--                   调度器查出后即丢弃(NO-OP READ)。页面自述「历史兼容只读」与代码事实一致。
+--
+-- 为什么改 Status 而不是 IsHide:
+--   这两页本就 IsHide=1(从不出现在侧边菜单树,只能从配置中心卡片进入),
+--   再设一次隐藏是空操作。Status=2(Disable) 才会让 SysMenuService 不再下发该路由,
+--   手输 URL 也进不去 —— 这才是「用户完全看不到」。
+--
+-- 为什么不 DELETE:
+--   1. 既有 SysRoleMenu 授权行会变成悬挂引用;
+--   2. 本批只做 Runtime Retirement,物理清理留待后续独立批次;
+--   3. 将来若要复活,只需改回 Status=1。
+--
+-- ⚠️ 绝不能顺手停用 aidopS8WatchRuleConfig(监控规则)—— 那是运行时权威本身。
+--    本脚本按 Name 精确匹配两条,不使用 LIKE '/aidop/s8/config/%' 之类的范围条件。
+--
+-- 幂等:带 Status <> 2 前置条件,重复执行不产生额外影响。
+
+UPDATE SysMenu
+   SET Status = 2,
+       Remark = 'S8 报警规则配置(已退役,历史保留)',
+       UpdateTime = NOW()
+ WHERE Name = 'aidopS8AlertRulesConfig'
+   AND Path = '/aidop/s8/config/alert-rules'
+   AND Status <> 2;
+
+UPDATE SysMenu
+   SET Status = 2,
+       Remark = 'S8 数据源配置(已退役,历史保留)',
+       UpdateTime = NOW()
+ WHERE Name = 'aidopS8DataSourceConfig'
+   AND Path = '/aidop/s8/config/data-sources'
+   AND Status <> 2;

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

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

+ 36 - 17
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantIsolationContractTests.cs

@@ -215,29 +215,48 @@ public class S8TenantIsolationContractTests
     }
 
     /// <summary>
-    /// 前端回填脱敏占位符时不得把 "******" 当作真实密码写库;未提交新密码则保留原密码。
+    /// 原用例 DataSourceEndpoint_MaskedPlaceholderDoesNotOverwriteStoredSecret 断言
+    /// <c>MergeEndpointPreservingSecret</c> 在前端回填 "******" 时保留库中真实密码。
+    ///
+    /// S8-LEGACY-RUNTIME-RETIREMENT-1 之后该辅助方法连同 <c>UpdateAsync</c> 的实现一并移除:
+    /// 数据源写入口全部退役,**不存在任何能改写 endpoint 的路径**。
+    /// 原保障("别把占位符当密码写进去")因此被更强的保障取代 ——
+    /// 不是"写的时候小心",而是"根本不能写"。
+    ///
+    /// 用反射断言写方法不可绕过,而不是简单删掉本用例:删掉会让
+    /// "凭据不会被覆盖" 这条安全属性失去看守,将来有人恢复写入口时无人报警。
     /// </summary>
     [Fact]
-    public void DataSourceEndpoint_MaskedPlaceholderDoesNotOverwriteStoredSecret()
+    public void DataSourceEndpoint_CannotBeOverwritten_BecauseAllWritesAreRetired()
     {
-        var merge = typeof(S8DataSourceService).GetMethod(
-            "MergeEndpointPreservingSecret", BindingFlags.Static | BindingFlags.NonPublic);
-        Assert.NotNull(merge);
+        // 写方法必须仍然存在(签名契约),但必须在任何 DB 访问前抛退役异常。
+        var svc = (S8DataSourceService)System.Runtime.CompilerServices.RuntimeHelpers
+            .GetUninitializedObject(typeof(S8DataSourceService));
+        var scope = new S8TrustedScope(838257186181189L, 838257186320453L);
 
-        const string stored = "Server=h;Uid=u;Pwd=RealSecret;";
-
-        // 1) 回填脱敏值 → 保留原始密码
-        var merged = (string)merge!.Invoke(null, ["Server=h;Uid=u;Pwd=******;", stored]);
-        Assert.Contains("Pwd=RealSecret", merged);
+        Assert.Throws<S8WriteRetiredException>(() =>
+        {
+            _ = svc.UpdateAsync(1L, new AdoS8DataSource
+            {
+                DataSourceCode = "UT_DS",
+                Type = "SQL",
+                Endpoint = "Server=h;Uid=u;Pwd=AttackerSuppliedValue;"
+            }, scope);
+        });
 
-        // 2) endpoint 为空 → 完全保留原值(不清空密码)
-        var kept = (string)merge.Invoke(null, [null, stored]);
-        Assert.Equal(stored, kept);
+        Assert.Throws<S8WriteRetiredException>(() =>
+        {
+            _ = svc.CreateAsync(new AdoS8DataSource
+            {
+                DataSourceCode = "UT_DS",
+                Type = "SQL",
+                Endpoint = "Server=h;Uid=u;Pwd=AttackerSuppliedValue;"
+            }, scope);
+        });
 
-        // 3) 明确提交新密码 → 才真正替换
-        var replaced = (string)merge.Invoke(null, ["Server=h;Uid=u;Pwd=BrandNew;", stored]);
-        Assert.Contains("Pwd=BrandNew", replaced);
-        Assert.DoesNotContain("RealSecret", replaced);
+        // 合并辅助方法已随写路径一并移除;若它重新出现,说明写路径可能被恢复,需要复核。
+        Assert.Null(typeof(S8DataSourceService).GetMethod(
+            "MergeEndpointPreservingSecret", BindingFlags.Static | BindingFlags.NonPublic));
     }
 
     /// <summary>

+ 30 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDataSourcesController.cs

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

+ 26 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -1251,6 +1251,20 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             IsHide = true,
             Remark = "S8 任务详情"
         };
+        // S8-LEGACY-RUNTIME-RETIREMENT-1:已退役的配置页(按 Name 匹配,不按 Id/顺序,
+        // 避免将来插入新条目导致偏移错位)。
+        //
+        //   · 数据源 —— S8 已确立为数据中台消费层,物理连接治理归数据平台;后端写入口同批退役。
+        //   · 报警规则 —— 运行时权威是 ado_s8_watch_rule(监控规则页);本表写入口早已 410、
+        //     evaluator 对其形参读取次数为 0。
+        //
+        // ⚠️ 不得把 aidopS8WatchRuleConfig(监控规则)加进来 —— 那是运行时权威本身。
+        var RetiredConfigMenuNames = new HashSet<string>(StringComparer.Ordinal)
+        {
+            "aidopS8AlertRulesConfig",
+            "aidopS8DataSourceConfig",
+        };
+
         var cfg = new (long Off, string Path, string Name, string Title, string Component)[]
         {
             (11, "/aidop/s8/config/scenes", "aidopS8ScenarioConfig", "场景基础配置", "/aidop/s8/config/S8ScenarioConfigPage"),
@@ -1258,6 +1272,9 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             // S8-CONFIG-AUTHORITY-FIX-1:该路由只承载操作员账号绑定;S8 鉴权走 SysMenu/SysRoleMenu,
             // 与本页无关。Name / Path / Component 保持不变(避免动态路由与既有授权失效),仅纠正 Title。
             (13, "/aidop/s8/config/roles", "aidopS8RolePermissionConfig", "操作员账号绑定", "/aidop/s8/config/S8RolePermissionConfigPage"),
+            // S8-LEGACY-RUNTIME-RETIREMENT-1:以下两条已停用(见下方 RetiredConfigMenuNames)。
+            // 条目本身保留:删掉会让既有 SysRoleMenu 授权行成为悬挂引用,
+            // 且 Name / Path / Component 是历史数据的定位锚点。
             (14, "/aidop/s8/config/alert-rules", "aidopS8AlertRulesConfig", "报警规则配置", "/aidop/s8/config/S8AlertRulesPage"),
             (15, "/aidop/s8/config/data-sources", "aidopS8DataSourceConfig", "数据源配置", "/aidop/s8/config/S8DataSourceConfigPage"),
             (16, "/aidop/s8/config/watch-rules", "aidopS8WatchRuleConfig", "监视规则配置", "/aidop/s8/config/S8WatchRuleConfigPage"),
@@ -1268,6 +1285,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         };
         foreach (var (off, path, name, title, component) in cfg)
         {
+            var retired = RetiredConfigMenuNames.Contains(name);
             yield return new SysMenu
             {
                 Id = baseId + off,
@@ -1281,7 +1299,14 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
                 CreateTime = ct,
                 OrderNo = 100 + (int)off,
                 IsHide = true,
-                Remark = $"S8 {title}"
+                // S8-LEGACY-RUNTIME-RETIREMENT-1:停用而非删除。
+                // Status=Disable 后动态路由不再注册该页,手输 URL 也进不去;
+                // 而条目仍在,既有 SysRoleMenu 授权不会变成悬挂引用,将来要复活也只需改回 Enable。
+                //
+                // 注意:这几页本就 IsHide=true(从不出现在侧边树,靠配置中心卡片进入),
+                // 所以「隐藏」对它们是空操作 —— 真正生效的是 Status。
+                Status = retired ? StatusEnum.Disable : StatusEnum.Enable,
+                Remark = retired ? $"S8 {title}(已退役,历史保留)" : $"S8 {title}"
             };
         }
 

+ 50 - 199
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DataSourceService.cs

@@ -1,51 +1,53 @@
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
-using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
-using Microsoft.Extensions.Logging;
-using System.Net.Sockets;
 using System.Text.RegularExpressions;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
+/// <summary>
+/// S8 数据源配置服务。
+///
+/// <b>S8-LEGACY-RUNTIME-RETIREMENT-1:本服务已退化为只读。</b>
+///
+/// 产品裁决:S8 = 数据中台**消费层**,不是数据接入层。
+/// <code>
+///   Data Platform → Governed Dataset → S8 Provider → Canonical Contract → Watch Rule
+/// </code>
+/// 物理数据源(连接串 / 主机 / 端口 / 凭据)的治理权归数据平台,不由租户 S8 管理员维护。
+///
+/// <b>为什么不能只隐藏 UI</b>:<c>ado_s8_data_source.endpoint</c> 存的是**含凭据的真实连接串**,
+/// 而 API 仍可被直接调用。写能力留着就等于「入口看不见但仍可写库、仍可让 S8 发起对外连接」。
+/// 故在服务层(写入的唯一收敛点)统一拒绝。
+///
+/// <b>为什么连接测试也退役</b>:<c>TestAsync</c> 虽只写 <c>last_check_*</c>,但它会用库里的 endpoint
+/// **真实建立外部数据库连接** —— 那正是本批要终止的「S8 维护物理连接」能力,
+/// 比单纯的行更新更需要关掉。随之移除 <c>S8SqlSugarScopeFactory</c> 依赖:
+/// 本服务从此**在结构上**不具备发起外连的能力,而不只是"约定不用"。
+///
+/// <b>保留的部分</b>:
+///   · <see cref="ListAsync"/> —— 存量 Legacy 规则仍绑着这些行,排障与历史核对需要能看到(endpoint 仍脱敏);
+///   · 写方法签名 —— 硬约束,<c>S8TenantIsolationContractTests</c> 用反射断言带
+///     <c>S8TrustedScope</c> 的写入口存在、且无 scope 的旧重载不存在;
+///   · <c>ado_s8_data_source</c> 表 / <c>S8DataSourceRowLoader</c> / <c>S8LegacySqlDataProvider</c> ——
+///     本批只做 Runtime Retirement,物理清理留待后续独立批次。
+/// </summary>
 public class S8DataSourceService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8DataSource> _rep;
-    // S8-STEP6A-CFG-DATASRC-FIX-1(D-1):真实连通性探测复用 evaluator 同款 scope 工厂,
-    // 保证 test 与 watch 运行期使用**同一** endpoint 规范化 / ConfigId / CommandTimeout 口径。
-    private readonly S8SqlSugarScopeFactory _scopeFactory;
-    private readonly ILogger<S8DataSourceService> _logger;
 
-    public S8DataSourceService(
-        SqlSugarRepository<AdoS8DataSource> rep,
-        S8SqlSugarScopeFactory scopeFactory,
-        ILogger<S8DataSourceService> logger)
+    public S8DataSourceService(SqlSugarRepository<AdoS8DataSource> rep)
     {
         _rep = rep;
-        _scopeFactory = scopeFactory;
-        _logger = logger;
     }
 
+    /// <summary>数据源写入口退役后的统一文案。与 Rule 侧退役文案分开:二者是两条不同的产品裁决。</summary>
+    public const string RetiredMessage =
+        "S8 已切换为数据中台 Dataset 消费模式,物理数据源由数据平台治理,"
+        + "S8 侧不再提供数据源的新增 / 修改 / 删除 / 连接测试。";
+
     /// <summary>
-    /// S8-STEP6A-CFG-DATASRC-FIX-1(D-5):配置面唯一 canonical type set = { SQL }。
-    /// 依据:① 三个租户既有行全部 type=SQL,从无 API 行;
-    ///       ② API 分支的 auth_type **零业务消费**(RowLoader 只把它写进 Debug 日志,从不构造 Authorization),
-    ///          故带鉴权的 API 源在当前实现下不可能成立;
-    ///       ③ API endpoint 可能在 URL query 携带 token/apikey,而 MaskSecret 只覆盖 Pwd/Password,
-    ///          且 RowLoader 会 LogDebug 完整 URL —— 按批次安全口径,API 不得进入「正式可选」状态。
-    /// 前端下拉原为 HTTP / SQL / MQ,其中 HTTP、MQ 均**不被 RowLoader 消费**(IsSupportedType 只认 SQL/API),
-    /// 存下来即产生 scheduler 阶段的 data_source_unavailable。本常量与前端选项保持单一事实源。
-    /// RowLoader 仍保留 API 分支以兼容历史行,但配置面已不可能再产出 API 行。
+    /// 只读列出本作用域的数据源。endpoint 中的 Pwd / Password 一律脱敏后返回。
     /// </summary>
-    private static readonly string[] SupportedTypes = { S8DataSourceRowLoader.SqlType };
-
-    private static string NormalizeType(string? type) => type?.Trim().ToUpperInvariant() ?? string.Empty;
-
-    private static void ValidateType(string? type)
-    {
-        if (!SupportedTypes.Contains(NormalizeType(type)))
-            throw new S8BizException($"不支持的数据源类型:{type};当前仅支持 {string.Join(" / ", SupportedTypes)}");
-    }
-
     public async Task<List<AdoS8DataSource>> ListAsync(long tenantId, long factoryId)
     {
         var rows = await _rep.AsQueryable()
@@ -55,158 +57,26 @@ public class S8DataSourceService : ITransient
         return rows;
     }
 
-    // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
-    public async Task<AdoS8DataSource> CreateAsync(AdoS8DataSource body, S8TrustedScope scope)
-    {
-        if (string.IsNullOrWhiteSpace(body.DataSourceCode) || string.IsNullOrWhiteSpace(body.Type))
-            throw new S8BizException("数据源编码和类型必填");
-        ValidateType(body.Type);
-        body.Type = NormalizeType(body.Type);
-        body.TenantId = scope.TenantId;
-        body.FactoryId = scope.FactoryId;
-        var exists = await _rep.AsQueryable()
-            .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.DataSourceCode == body.DataSourceCode);
-        if (exists) throw new S8BizException("数据源编码已存在");
-        body.Id = 0;
-        body.CreatedAt = DateTime.Now;
-        // S8-STEP6A-CFG-DATASRC-FIX-1(D-3):回填自增主键。原 InsertAsync 只返回 bool,
-        // body.Id 保持 0,调用方拿到 id=0 后 GET/PUT/POST test/DELETE 一律 404。
-        // 采用仓内既有写法(同 S8ExceptionTypeService 的 AsInsertable(...).ExecuteReturnBigIdentityAsync)。
-        body.Id = await _rep.AsInsertable(body).ExecuteReturnBigIdentityAsync();
-        body.Endpoint = MaskSecret(body.Endpoint);
-        return body;
-    }
-
-    // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。
-    public async Task<AdoS8DataSource> UpdateAsync(long id, AdoS8DataSource body, S8TrustedScope scope)
-    {
-        var e = await LoadScopedAsync(id, scope);
-        if (string.IsNullOrWhiteSpace(body.DataSourceCode) || string.IsNullOrWhiteSpace(body.Type))
-            throw new S8BizException("数据源编码和类型必填");
-        ValidateType(body.Type);
-        body.Type = NormalizeType(body.Type);
-        var exists = await _rep.AsQueryable()
-            .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId && x.FactoryId == e.FactoryId && x.DataSourceCode == body.DataSourceCode);
-        if (exists) throw new S8BizException("数据源编码已存在");
-        // 入参 endpoint 含掩码占位符(Pwd=****** / Password=******)时保留旧值的真实密码段,避免前端
-        // 回填脱敏值后误覆盖。Endpoint 全空时也不覆盖原密码。
-        body.Endpoint = MergeEndpointPreservingSecret(body.Endpoint, e.Endpoint);
-        body.Id = id;
-        body.TenantId = e.TenantId;
-        body.FactoryId = e.FactoryId;
-        body.CreatedAt = e.CreatedAt;
-        // S8-STEP6A-CFG-DATASRC-FIX-1(D-4):last_check_* 属**连接测试产生的系统状态**,
-        // 不是表单可编辑字段。原实现用请求体整行覆盖,导致「编辑一次备注就把最近检测结果清空」,
-        // 而列表「最近检测」列正是读这两列。此处一律沿用库中既有值。
-        body.LastCheckAt = e.LastCheckAt;
-        body.LastCheckStatus = e.LastCheckStatus;
-        body.UpdatedAt = DateTime.Now;
-        await _rep.UpdateAsync(body);
-        body.Endpoint = MaskSecret(body.Endpoint);
-        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);
-    }
-
-    /// <summary>按 Id + 可信作用域取行;不在作用域内一律按「不存在」处理,不泄露他租户资源是否存在。</summary>
-    private async Task<AdoS8DataSource> 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();
+    // ================================================================================
+    // 写入口:全部退役。
+    //
+    // ⚠️ 抛出发生在**任何 DB 访问之前**:不做 LoadScopedAsync、不做重复性查询。
+    //    这既保证零 DB 触碰,也保证任意 id(含越权 id)一律 410 而非 404
+    //    ——「这个能力没了」优先于「这条记录不属于你」,
+    //    避免用越权探测反推他租户资源是否存在。
+    // ================================================================================
 
-    /// <summary>
-    /// S8-STEP6A-CFG-DATASRC-FIX-1(D-1):真实连通性探测,取代原「endpoint 非空即 SUCCESS」的伪实现。
-    /// 原实现对不可解析主机(实测 aidopdev.local,NXDOMAIN)同样返回 SUCCESS,使「最近检测」列不可信。
-    /// 本实现与 watch 运行期同源:S8SqlSugarScopeFactory.CreateScope(同 endpoint 规范化 / ConfigId /
-    /// CommandTimeout)→ 只读 `SELECT 1`。**不读业务表、不执行 rule expression、无 DDL/DML。**
-    /// 失败一律归类为固定标签,**绝不把底层异常原文、连接串或密码回传前端 / 写入 last_check_status**。
-    /// </summary>
-    public async Task<object> TestAsync(long id, S8TrustedScope scope)
-    {
-        var entity = await LoadScopedAsync(id, scope);
-        var (success, status, message) = await ProbeAsync(entity);
+    public Task<AdoS8DataSource> CreateAsync(AdoS8DataSource body, S8TrustedScope scope) =>
+        throw new S8WriteRetiredException(RetiredMessage);
 
-        entity.LastCheckAt = DateTime.Now;
-        entity.LastCheckStatus = status;
-        entity.UpdatedAt = DateTime.Now;
-        await _rep.UpdateAsync(entity);
+    public Task<AdoS8DataSource> UpdateAsync(long id, AdoS8DataSource body, S8TrustedScope scope) =>
+        throw new S8WriteRetiredException(RetiredMessage);
 
-        // 只记录分类结果与数据源标识,不记录 endpoint / 连接串 / 密码。
-        _logger.LogInformation(
-            "s8_data_source_test id={Id} code={Code} tenantId={TenantId} factoryId={FactoryId} status={Status}",
-            entity.Id, entity.DataSourceCode, entity.TenantId, entity.FactoryId, status);
-
-        return new { id, success, message, entity.LastCheckAt, entity.LastCheckStatus };
-    }
+    public Task DeleteAsync(long id, S8TrustedScope scope) =>
+        throw new S8WriteRetiredException(RetiredMessage);
 
-    private const string ProbeSql = "SELECT 1";
-
-    private async Task<(bool Success, string Status, string Message)> ProbeAsync(AdoS8DataSource entity)
-    {
-        if (string.IsNullOrWhiteSpace(entity.Endpoint))
-            return (false, "FAILED: endpoint is empty", "连接地址为空,未通过校验");
-        if (!SupportedTypes.Contains(NormalizeType(entity.Type)))
-            return (false, "FAILED: unsupported type", $"不支持的数据源类型:{entity.Type}");
-
-        var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
-        try
-        {
-            using var db = _scopeFactory.CreateScope(
-                entity.Endpoint!, _rep.Context.CurrentConnectionConfig.DbType, timeoutSeconds);
-            await db.Ado.GetScalarAsync(ProbeSql);
-            return (true, "SUCCESS", "连接成功(已建立连接并执行 SELECT 1)");
-        }
-        catch (Exception ex)
-        {
-            var (status, message) = ClassifyProbeFailure(ex);
-            return (false, status, message);
-        }
-    }
-
-    /// <summary>
-    /// 把底层连接异常收敛为**固定标签 + 安全文案**。
-    /// 只读取异常的类型与关键字用于分类,**不把 ex.Message 原文写入返回值或 last_check_status** ——
-    /// 驱动异常常在 message 中回显完整连接串(含 Uid/Pwd)。
-    /// </summary>
-    private static (string Status, string Message) ClassifyProbeFailure(Exception ex)
-    {
-        var text = Flatten(ex);
-
-        if (Contains(text, "no such host", "name or service not known", "unknown host", "getaddrinfo", "name does not resolve"))
-            return ("FAILED: host unresolved", "连接失败:主机无法解析,请检查连接地址中的主机名");
-        if (Contains(text, "actively refused", "connection refused", "refused it"))
-            return ("FAILED: connection refused", "连接失败:目标主机拒绝连接,请检查端口与服务状态");
-        if (Contains(text, "access denied", "authentication", "auth_failed", "password"))
-            return ("FAILED: authentication", "连接失败:认证未通过,请检查账号或密码配置");
-        if (Contains(text, "unknown database", "database does not exist"))
-            return ("FAILED: database not found", "连接失败:目标数据库不存在,请检查库名");
-        if (ex is TimeoutException || ex is OperationCanceledException
-            || Contains(text, "timeout", "timed out"))
-            return ("FAILED: timeout", "连接失败:连接或查询超时");
-        if (ex is SocketException || Contains(text, "unable to connect", "network"))
-            return ("FAILED: network unreachable", "连接失败:网络不可达");
-
-        // S8-CFG-DATASRC-CHECKPOINT-COMMIT-1(诚实性微修):不再声称「详情见服务端日志」——
-        // 本服务的日志只记 id/code/tenantId/factoryId/status,**刻意不记录底层异常详情**
-        // (驱动异常 message 常回显完整连接串含 Uid/Pwd)。为了让旧文案成立而去记 ex.Message
-        // 会直接制造凭据泄漏,故改文案、不改日志。分类 / status / HTTP 契约 / 探测行为均不变。
-        return ("FAILED: connection error", "连接失败,请检查连接配置或联系管理员");
-    }
-
-    private static string Flatten(Exception ex)
-    {
-        var parts = new List<string>();
-        for (var cur = ex; cur != null; cur = cur.InnerException) parts.Add(cur.Message ?? string.Empty);
-        return string.Join(" | ", parts).ToLowerInvariant();
-    }
-
-    private static bool Contains(string haystack, params string[] needles) =>
-        needles.Any(n => haystack.Contains(n, StringComparison.Ordinal));
+    public Task<object> TestAsync(long id, S8TrustedScope scope) =>
+        throw new S8WriteRetiredException(RetiredMessage);
 
     // BUG-13:endpoint 中的 Pwd=xxx / Password=xxx(大小写不敏感)替换为 ******,保留其它字段。
     private static readonly Regex SecretPattern = new(
@@ -218,23 +88,4 @@ public class S8DataSourceService : ITransient
         if (string.IsNullOrWhiteSpace(endpoint)) return endpoint;
         return SecretPattern.Replace(endpoint, m => $"{m.Groups[1].Value}=******");
     }
-
-    private static string? MergeEndpointPreservingSecret(string? incoming, string? existing)
-    {
-        if (string.IsNullOrWhiteSpace(incoming)) return existing;
-        if (string.IsNullOrWhiteSpace(existing)) return incoming;
-        // 提取旧 endpoint 中的真实密码值(首个匹配为准)
-        var oldMatch = SecretPattern.Match(existing);
-        if (!oldMatch.Success) return incoming;
-        var realSecret = oldMatch.Groups[2].Value;
-        // 把入参里 Pwd=****** 之类的占位还原为真实密码
-        return SecretPattern.Replace(incoming, m =>
-        {
-            var v = m.Groups[2].Value;
-            return IsMaskedPlaceholder(v) ? $"{m.Groups[1].Value}={realSecret}" : m.Value;
-        });
-    }
-
-    private static bool IsMaskedPlaceholder(string? v) =>
-        !string.IsNullOrEmpty(v) && v.All(c => c == '*');
 }