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

feat(s8): 规则创建改为选择治理数据集,向导不再触碰 SQL 与物理数据源

Hard Cutover 第三步:把「没有数据集就没有可运行规则」落到用户实际操作的入口。

新增只读数据集目录 GET /api/aidop/s8/config/datasets
取代已删除的 data-sources 端点,但二者性质不同,不是改名:
  · 旧的是租户可写的物理连接配置(endpoint 存含凭据的连接串);
  · 新的是平台级只读目录,内容来自代码内的 Catalog + Provider Registry,
    没有任何写入口——数据集的增减是数据平台侧的开发行为,不是租户配置行为。
只返回「已定义 + 未停用 + Provider 已注册」的数据集:让用户选到一个"定义了
但没实现"的数据集,只会把失败推迟到启用那一刻(dataset_provider_not_registered),
对配置者毫无帮助。同时回传三个能力位供前端按报警机制预过滤,
使 dataset_capability_mismatch 在下拉阶段就不可能被选出来。

向导前端
- dataSources prop → datasets;新增 form.datasetCode 并纳入 wizard_json 序列化
  与草稿回填(后端 RebuildPayloadFromWizardJsonAsync 正是从这里读取)。
- 「生成正式规则」按钮恢复可用(上一批因只能产出 Legacy 规则而被停用),
  改为在无可用数据集时禁用,并把「当前无可用治理数据集」提示前置到向导顶部,
  而不是让用户填完五步才在提交时被拒。
- 规则详情的「检测 SQL(只读)」区块改为「取数数据集(只读)」——
  expression 字段已随后端删除。
- s8ConfigApi 的 dataSourceId / expression 字段同步移除,改为 datasetCode。

清理最后一条死字段链
三个 evaluator 不再给 S8RuleHit.DataSourceId 赋值后,该字段连同
S8WatchHitResult.DataSourceId、ToWatchHit 的传递、以及建单时的
SourceDataSourceId 写入全部成为恒 0/恒 NULL 的废管道,一并删除。
ado_s8_exception.source_data_source_id 列本身保留:历史异常单里存着真实的
旧数据源 Id,删列会让那段血缘无法解释;新单该列恒为 NULL。

测试 S8DatasetOnlyRuleCreationTests
- T7:断言 BuildExpression / ValidateSqlIdentifier / BuildSoftDeletePredicate
  三个方法名在 S8ConfigDraftService 上不存在。逐名断言而非只看行为,
  是因为它们是 private——行为测试删掉一个分支就能变绿,名字还在就说明能力还在。
- T8:10 个 Legacy 类型的完全限定名逐一断言不存在。
- 数据集端点必须零写动作(无 HttpPost/Put/Delete/Patch)。
- T11:Rule 01 的配置形态在删掉三个 Legacy 列后仍完整可表达,并能通过保存级校验——
  它本来就只依赖 dataset_code,这正是当初把它建成 STANDARD_DATASET 的意义。

chore: bump version Web 2.4.380 / server 1.0.488
YY968XX пре 1 дан
родитељ
комит
b8e3e2a99c

+ 1 - 1
Web/package.json

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

+ 18 - 7
Web/src/views/aidop/s8/api/s8ConfigApi.ts

@@ -103,9 +103,10 @@ export interface S8WatchRuleConfigRow {
 	factoryId: number | string;
 	ruleCode: string;
 	sceneCode: string;
-	dataSourceId: number | string;
+	// S8-STANDARD-DATASET-HARD-CUTOVER-1:dataSourceId / expression 已随后端物理删除。
+	// 取数唯一配置是 datasetCode。
+	datasetCode?: string | null;
 	watchObjectType: string;
-	expression?: string | null;
 	severity: string;
 	pollIntervalSeconds: number;
 	enabled: boolean;
@@ -210,12 +211,22 @@ export interface S8MonitorOptionsResult {
 }
 
 // CONFIG-RULE-WIZARD-MVP-1:向导新建规则的最小字段集合,复用 legacy POST /api/aidop/s8/config/watch-rules。
+/// 治理数据集(只读目录项)。来自 Catalog + Provider Registry,无写入口。
+export interface S8DatasetOption {
+	datasetCode: string;
+	displayName?: string;
+	kind?: string;
+	supportsTimeout?: boolean;
+	supportsShortage?: boolean;
+	supportsOutOfRange?: boolean;
+}
+
 export interface S8WatchRuleCreatePayload {
 	tenantId?: number;
 	factoryId?: number;
 	ruleCode: string;
 	sceneCode: string;
-	dataSourceId: number;
+	datasetCode: string;
 	watchObjectType: string;
 	ruleType: string;
 	ruleMechanism: string;
@@ -229,8 +240,6 @@ export interface S8WatchRuleCreatePayload {
 	consecutiveFailureCount: number;
 	enabled: boolean;
 	paramsJson: string;
-	// CONFIG-RULE-WIZARD-BIZ-CONDITION-MVP-1:占位 SQL 表达式(WHERE 1=0),保证启用后不真命中。
-	expression?: string;
 }
 
 export const s8ConfigApi = {
@@ -256,8 +265,10 @@ export const s8ConfigApi = {
 	dimensionNodes: (params: { tenantId?: number; factoryId?: number; dimensionCode: string }) =>
 		service.get<S8DimensionNode[]>('/api/aidop/s8/config/dimension-nodes', { params: withAidopTenantFactoryParams(params) }).then(unwrap),
 	severities: () => service.get('/api/aidop/s8/dictionaries/S8_EXCEPTION_SEVERITY', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
-	dataSources: (params?: { tenantId?: number; factoryId?: number }) =>
-		service.get('/api/aidop/s8/config/data-sources', { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
+	// S8-STANDARD-DATASET-HARD-CUTOVER-1:dataSources 端点已物理删除(S8 不再维护物理数据源)。
+	// 取数目录改由 datasets 提供:Catalog 已定义 + Provider 已注册 才算可用。
+	datasets: () =>
+		service.get<S8DatasetOption[]>('/api/aidop/s8/config/datasets', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 	importRolesFromSys: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.post('/api/aidop/s8/config/roles/import-from-sys', null, { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
 	operatorBindings: {

+ 12 - 9
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -1139,13 +1139,16 @@ async function toggleEnabled(row: S8WatchRuleConfigRow) {
 
 // CONFIG-RULE-WIZARD-MVP-1:添加规则向导。
 const wizardVisible = ref(false);
-const dataSources = ref<any[]>([]);
-async function loadDataSources() {
+// S8-STANDARD-DATASET-HARD-CUTOVER-1:数据源列表已由治理数据集列表取代。
+// 只有 Catalog 已定义**且** Provider 已注册的数据集才会出现在这里 ——
+// 让用户在向导里选到一个"定义了但没实现"的数据集,只会把失败推迟到启用那一刻。
+const datasets = ref<any[]>([]);
+async function loadDatasets() {
 	try {
-		const list = await s8ConfigApi.dataSources({ tenantId: TENANT_ID, factoryId: FACTORY_ID });
-		dataSources.value = Array.isArray(list) ? list : [];
+		const list = await s8ConfigApi.datasets();
+		datasets.value = Array.isArray(list) ? list : [];
 	} catch {
-		dataSources.value = [];
+		datasets.value = [];
 	}
 }
 function openWizard() {
@@ -1215,7 +1218,7 @@ onMounted(() => {
 	loadRows();
 	loadExceptionTypes();
 	loadDimensionNodes();
-	loadDataSources();
+	loadDatasets();
 	loadMonitorOptions();
 	loadDraftCount();
 	startAutoRefresh();
@@ -1276,7 +1279,7 @@ onDeactivated(() => stopAutoRefresh());
 			:stage-nodes="stageNodes"
 			:order-flow-nodes="orderFlowNodes"
 			:exception-types="exceptionTypes"
-			:data-sources="dataSources"
+			:datasets="datasets"
 			:monitor-objects="monitorObjects"
 			:tenant-id="TENANT_ID"
 			:factory-id="FACTORY_ID"
@@ -1682,8 +1685,8 @@ onDeactivated(() => stopAutoRefresh());
 						<el-descriptions-item label="源对象">{{ editingRow.sourceObjectType ?? editingRow.watchObjectType ?? '—' }}</el-descriptions-item>
 					</el-descriptions>
 
-					<el-divider content-position="left">检测 SQL(只读)</el-divider>
-					<pre class="rule-expr">{{ editingRow.expression || '—' }}</pre>
+					<el-divider content-position="left">取数数据集(只读)</el-divider>
+					<pre class="rule-expr">{{ editingRow.datasetCode || '—' }}</pre>
 				</template>
 
 				<el-divider content-position="left">检测调度</el-divider>

+ 61 - 40
Web/src/views/aidop/s8/config/components/WatchRuleWizardDialog.vue

@@ -19,11 +19,14 @@ import InlineCreateExceptionTypeDialog from './InlineCreateExceptionTypeDialog.v
 import S8FieldTooltip from '../../components/S8FieldTooltip.vue';
 import { resolveAidopFactoryId, resolveAidopTenantId } from '../../../api/aidopTenant';
 
-interface DataSourceLike {
-	id: number;
-	enabled?: boolean;
-	dataSourceCode?: string;
-	type?: string;
+// S8-STANDARD-DATASET-HARD-CUTOVER-1:物理数据源已不存在,向导改为选择治理数据集。
+// 列表由 /api/aidop/s8/config/datasets 提供,只含「Catalog 已定义 + Provider 已注册」的项。
+interface DatasetOption {
+	datasetCode: string;
+	displayName?: string;
+	supportsTimeout?: boolean;
+	supportsShortage?: boolean;
+	supportsOutOfRange?: boolean;
 }
 
 const props = defineProps<{
@@ -31,7 +34,7 @@ const props = defineProps<{
 	stageNodes: S8DimensionNode[];
 	orderFlowNodes: S8DimensionNode[];
 	exceptionTypes: S8ExceptionTypeConfigRow[];
-	dataSources: DataSourceLike[];
+	datasets: DatasetOption[];
 	// CONFIG-MONITOR-DICT-READONLY-SEED-1:监控对象/指标后端字典,空数组时回退 FALLBACK_MONITOR_OPTIONS
 	monitorObjects?: S8MonitorObjectOption[];
 	tenantId?: number;
@@ -155,6 +158,7 @@ const form = reactive({
 	metricCode: '',
 	exceptionTypeCode: '',
 	severity: 'FOLLOW',
+	datasetCode: '',
 	graceMinutes: 0,
 	completedStates: 'CLOSED,COMPLETED,DONE',
 	lowerBound: null as number | null,
@@ -169,6 +173,17 @@ const form = reactive({
 	recoverCountRequired: 1,
 });
 
+// 按当前报警机制过滤可选数据集:能力不匹配的组合在这里就该看不见,
+// 而不是等提交后被 Enable Gate 以 dataset_capability_mismatch 拒绝。
+// 机制 → rule_type 的对应与后端 RuleTypeOf 一致:DATE→TIMEOUT / VALUE_RANGE→OUT_OF_RANGE。
+const datasetOptions = computed<DatasetOption[]>(() => {
+	const all = props.datasets ?? [];
+	if (form.mechanism === 'DATE') return all.filter((d) => d.supportsTimeout !== false);
+	if (form.mechanism === 'VALUE_RANGE') return all.filter((d) => d.supportsOutOfRange !== false);
+	// RATIO 当前不可选(上游已拦),MANUAL_REPORT 不生成规则;两者都不需要数据集过滤。
+	return all;
+});
+
 // CONFIG-WIZARD-DRAFT-FRONTEND-1:草稿身份 + 用户可改的草稿名。
 const currentDraftId = ref<number | string | null>(null);
 const currentDraftCode = ref<string>('');
@@ -253,6 +268,7 @@ function resetForm() {
 	form.toleranceRatioPct = 0;
 	form.targetRatio = 95;
 	form.ruleCode = '';
+	form.datasetCode = '';
 	form.pollIntervalSeconds = 300;
 	form.triggerCountRequired = 1;
 	form.recoverCountRequired = 1;
@@ -490,6 +506,7 @@ function serializeWizardState(): string {
 			orderFlowCode: form.orderFlowCode,
 			exceptionTypeCode: form.exceptionTypeCode,
 			severity: form.severity,
+			datasetCode: form.datasetCode,
 			objectType: obj?.objectType ?? '',
 			objectLabel: obj?.objectLabel ?? '',
 			metricCode: form.metricCode,
@@ -555,6 +572,7 @@ function hydrateFromDraft(draft: S8WizardDraftDetail) {
 	if (f.orderFlowCode) form.orderFlowCode = f.orderFlowCode;
 	if (f.exceptionTypeCode) form.exceptionTypeCode = f.exceptionTypeCode;
 	if (f.severity) form.severity = f.severity;
+	if (typeof f.datasetCode === 'string') form.datasetCode = f.datasetCode;
 
 	// 2) 反查 activeMonitorOptions(后端字典优先):按 (objectType, objectLabel) 在当前机制下定位 objectIndex
 	const candidates = activeMonitorOptions.value.filter((o) => o.mechanisms.includes(form.mechanism as any));
@@ -746,6 +764,11 @@ function validateGenerate(): boolean {
 	if (isOrderFlowStage.value && !form.orderFlowCode) { ElMessage.warning('订单链路阶段规则必须选择订单流程节点'); return false; }
 	if (!form.exceptionTypeCode) { ElMessage.warning('异常类型未选择'); return false; }
 	if (!form.ruleCode.trim()) { ElMessage.warning('规则编码不能为空'); return false; }
+	if (!form.datasetCode) {
+		// 没有治理数据集就不存在可运行规则 —— 绝不退回"先建着、以后再说"。
+		ElMessage.warning(datasetOptions.value.length === 0 ? '当前无可用治理数据集' : '请选择取数数据集');
+		return false;
+	}
 	if (form.pollIntervalSeconds <= 0) { ElMessage.warning('轮询间隔必须大于 0'); return false; }
 	if (form.mechanism === 'DATE') {
 		const states = form.completedStates.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
@@ -761,20 +784,10 @@ function validateGenerate(): boolean {
 	return true;
 }
 
-// S8-LEGACY-RUNTIME-RETIREMENT-1:向导生成的是 LEGACY_SQL 规则,已无运行资格。
-// 按钮已 disabled,这里再加一道前端守卫 —— 按钮的 disabled 只是 UI 状态,
-// 而后端 S8RuleCreationPolicy.EnsureCreationAllowed 才是真正的门(会返回 legacy_sql_retired)。
-// 保留函数本体不删:数据集治理完成后本向导要迁到 STANDARD_DATASET,届时这里是改造起点。
-const RULE_GENERATION_RETIRED_HINT =
-	'向导不再生成 Legacy SQL 规则。S8 仅运行 STANDARD_DATASET 规则,请先在数据中台完成该监控对象的数据集治理与 Provider 上线。';
-// 显式标注 boolean(而非让 TS 推成字面量 true),否则后续代码会被判定为不可达。
-const RULE_GENERATION_RETIRED: boolean = true;
-
+// S8-STANDARD-DATASET-HARD-CUTOVER-1:向导重新可用,但只能生成 STANDARD_DATASET 规则。
+// 上一版这里有一道「向导已停用」的硬守卫,因为当时它生成的是 Legacy SQL;
+// 现在 SQL 生成机构已从 S8ConfigDraftService 整体删除,向导只组装 dataset_code + params_json。
 async function generateRuleFromDraft() {
-	if (RULE_GENERATION_RETIRED) {
-		ElMessage.warning(RULE_GENERATION_RETIRED_HINT);
-		return;
-	}
 	if (!validateGenerate()) return;
 	saving.value = true;
 	try {
@@ -827,17 +840,17 @@ function handleInlineExceptionTypeCreated(payload: { typeCode: string; typeName:
 		</el-steps>
 
 		<!--
-			S8-LEGACY-RUNTIME-RETIREMENT-1:把「本向导已不能生成可运行规则」放在最前面,
+			S8-STANDARD-DATASET-HARD-CUTOVER-1:把「有没有可用数据集」放在最前面,
 			而不是让用户填完五步再在提交时被拒。草稿仍可保存,供数据集治理完成后复用。
 		-->
 		<el-alert
-			v-if="!isManualReport"
-			type="info"
+			v-if="!isManualReport && datasetOptions.length === 0"
+			type="warning"
 			show-icon
 			:closable="false"
 			style="margin-bottom: 12px"
-			title="自动规则生成已停用"
-			description="S8 仅运行 STANDARD_DATASET 规则(数据取自数据中台治理数据集)。本向导生成的是 Legacy SQL 规则,已不具备运行资格。可继续保存草稿,待该监控对象完成数据集治理与 Provider 上线后再建规则。"
+			title="当前无可用治理数据集"
+			description="S8 只能基于数据中台的治理数据集创建规则。当前没有任何「已定义且已上线 Provider」的数据集,因此无法生成可运行规则。可先保存草稿,待数据集治理完成后继续。"
 		/>
 
 		<el-alert
@@ -1116,6 +1129,24 @@ function handleInlineExceptionTypeCreated(payload: { typeCode: string; typeName:
 					<el-input v-model="draftName" placeholder="留空则自动生成" maxlength="128" show-word-limit />
 					<span class="wizard-hint">草稿名仅用于草稿配置列表展示,可随时修改</span>
 				</el-form-item>
+				<el-form-item label="取数数据集" required>
+					<el-select
+						v-model="form.datasetCode"
+						placeholder="选择治理数据集"
+						style="width: 100%"
+						:no-data-text="datasets.length === 0 ? '当前无可用治理数据集' : '当前规则类型没有匹配的数据集'"
+					>
+						<el-option
+							v-for="d in datasetOptions"
+							:key="d.datasetCode"
+							:label="d.displayName ? `${d.displayName}(${d.datasetCode})` : d.datasetCode"
+							:value="d.datasetCode"
+						/>
+					</el-select>
+					<span class="wizard-hint">
+						只列出已上线 Provider、且支持当前报警机制的数据集。S8 不再连接物理数据源,取数一律经数据中台。
+					</span>
+				</el-form-item>
 				<el-form-item required>
 					<template #label>
 						<S8FieldTooltip field-key="rule_code" fallback-label="规则编码" />
@@ -1149,23 +1180,13 @@ function handleInlineExceptionTypeCreated(payload: { typeCode: string; typeName:
 				<el-button @click="visible = false">取消</el-button>
 				<el-button :loading="savingDraft" @click="saveDraft">保存草稿</el-button>
 				<el-button v-if="step < 4" type="primary" @click="nextStep">下一步</el-button>
-				<!--
-					S8-LEGACY-RUNTIME-RETIREMENT-1:向导的自动规则生成能力已停用。
-					该路径生成的是 LEGACY_SQL 规则(服务端按字典拼 SQL + 绑定 ado_s8_data_source),
-					而 STANDARD_DATASET 已成为唯一允许运行的取数模式,生成出来的规则永远无法启用。
-					后端 S8RuleCreationPolicy 已硬拒(这才是真正的门),此处只是把结论提前告诉用户,
-					避免走完五步表单才在最后一步吃报错。
-					草稿保存与 MANUAL_REPORT 模板路径均不受影响,故只禁用本按钮、不下线整个向导。
-				-->
-				<el-tooltip
+				<el-button
 					v-else
-					content="向导不再生成 Legacy SQL 规则。S8 仅运行 STANDARD_DATASET 规则,需先在数据中台完成该监控对象的数据集治理与 Provider 上线。"
-					placement="top"
-				>
-					<span>
-						<el-button type="primary" disabled @click="generateRuleFromDraft">生成正式规则(已停用)</el-button>
-					</span>
-				</el-tooltip>
+					type="primary"
+					:loading="saving"
+					:disabled="dictionaryMissing || datasetOptions.length === 0"
+					@click="generateRuleFromDraft"
+				>生成正式规则</el-button>
 			</template>
 		</template>
 	</el-dialog>

+ 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.487</AssemblyVersion>
-    <FileVersion>1.0.487</FileVersion>
-    <Version>1.0.487</Version>
+    <AssemblyVersion>1.0.488</AssemblyVersion>
+    <FileVersion>1.0.488</FileVersion>
+    <Version>1.0.488</Version>
   </PropertyGroup>
 
   <ItemGroup>

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

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

+ 56 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDatasetsController.cs

@@ -0,0 +1,56 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+/// <summary>
+/// 治理数据集目录(只读)。
+///
+/// S8-STANDARD-DATASET-HARD-CUTOVER-1:取代已物理删除的「数据源」配置端点。
+/// 二者的性质完全不同,不要当成改名:
+///   · 旧的 data-sources 是**租户可写的物理连接配置**(含连接串与凭据);
+///   · 本端点是**平台级只读目录**,内容来自代码内的 Catalog + Provider Registry,
+///     没有任何写入口 —— 数据集的增减是数据平台侧的开发行为,不是租户配置行为。
+///
+/// 只返回 <b>Catalog 已定义 且 Provider 已注册 且 定义未停用</b> 的数据集:
+/// 让用户在向导里选到一个"定义了但没实现"的数据集,只会把失败推迟到启用那一刻
+/// (dataset_provider_not_registered),对配置者毫无帮助。
+/// </summary>
+[ApiController]
+[Route("api/aidop/s8/config/datasets")]
+[NonUnify]
+public class AdoS8ConfigDatasetsController : ControllerBase
+{
+    private readonly IS8DatasetCatalog _catalog;
+    private readonly S8MonitoringDataProviderRegistry _registry;
+
+    public AdoS8ConfigDatasetsController(IS8DatasetCatalog catalog, S8MonitoringDataProviderRegistry registry)
+    {
+        _catalog = catalog;
+        _registry = registry;
+    }
+
+    [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
+    public IActionResult ListAsync()
+    {
+        var rows = _catalog.All
+            .Where(d => d.IsActive && _registry.IsRegistered(d.DatasetCode))
+            .OrderBy(d => d.DatasetCode, StringComparer.Ordinal)
+            .Select(d => new
+            {
+                datasetCode = d.DatasetCode,
+                displayName = d.DisplayName,
+                kind = d.Kind.ToString(),
+                // 供前端在选择规则类型时先行过滤:能力不匹配的组合在这里就该看不见,
+                // 而不是等提交后被 Enable Gate 以 dataset_capability_mismatch 拒绝。
+                supportsTimeout = d.Capabilities.SupportsTimeout,
+                supportsShortage = d.Capabilities.SupportsShortage,
+                supportsOutOfRange = d.Capabilities.SupportsOutOfRange
+            })
+            .ToList();
+
+        return Ok(rows);
+    }
+}

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

@@ -38,7 +38,6 @@ public sealed class S8RuleHit
     public string SourcePayload { get; set; } = "{}";
     public DateTime DetectedAt { get; set; } = DateTime.Now;
     public string Title { get; set; } = string.Empty;
-    public long DataSourceId { get; set; }
     public long? OccurrenceDeptId { get; set; }
     public long? ResponsibleDeptId { get; set; }
 

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -766,7 +766,8 @@ public class S8ManualReportService : ITransient
             ProcessNodeCode = null,
             // 追溯三件套(自动建单必填口径)。
             SourceRuleId = hit.SourceRuleId,
-            SourceDataSourceId = hit.DataSourceId,
+            // S8-STANDARD-DATASET-HARD-CUTOVER-1:物理数据源概念已不存在,此列不再写入。
+            // 列本身保留:历史异常单里存着真实的旧数据源 Id,删列会让那段血缘无法解释。
             SourcePayload = hit.SourcePayload,
             RelatedObjectCode = hit.RelatedObjectCode,
         };
@@ -894,7 +895,7 @@ public class S8ManualReportService : ITransient
             // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
             ProcessNodeCode = null,
             SourceRuleId = hit.SourceRuleId,
-            SourceDataSourceId = hit.DataSourceId == 0 ? null : hit.DataSourceId,
+            // 同上:不再写 source_data_source_id,历史行的值原样保留。
             SourcePayload = hit.SourcePayload,
             RelatedObjectCode = hit.RelatedObjectCode,
             // R2 新列回填

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

@@ -666,7 +666,6 @@ public class S8WatchSchedulerService : ITransient
     {
         SourceRuleId = hit.SourceRuleId == 0 ? rule.Id : hit.SourceRuleId,
         SourceRuleCode = string.IsNullOrEmpty(hit.SourceRuleCode) ? rule.RuleCode : hit.SourceRuleCode,
-        DataSourceId = hit.DataSourceId,
         RelatedObjectCode = hit.RelatedObjectCode,
         Severity = hit.Severity,
         OccurrenceDeptId = hit.OccurrenceDeptId,
@@ -1398,7 +1397,6 @@ public sealed class S8WatchHitResult
 {
     public long SourceRuleId { get; set; }
     public string SourceRuleCode { get; set; } = string.Empty;
-    public long DataSourceId { get; set; }
     public string RelatedObjectCode { get; set; } = string.Empty;
     public decimal CurrentValue { get; set; }
     public decimal ThresholdValue { get; set; }