Jelajahi Sumber

feat(kpi): add configurable SQL calculation runtime

KPI SQL 配置化计算最小闭环(试点 S5_L1_001):
- 配置表 ado_smart_ops_kpi_calc_config(版本/发布状态机/单生效版本事务)+ 运行日志 ado_smart_ops_kpi_calc_run_log
- SQL 安全校验(5层非正则:去注释/单语句/起始token白名单/token denylist/FROM表白名单)+ 只读事务执行器(START TRANSACTION READ ONLY,DB强制拒写+超时+单行契约)
- 运行时分发器接入 RunFullAsync(仅S5_L1_001):LEGACY_CODE/CONFIG_SQL/LEGACY_TVF,失败不fallback;数据准备(dwd明细)保留,仅接管最终聚合
- Controller 要求登录(去AllowAnonymous);前端 kpiMaster 加计算配置Tab + SqlEditor(Monaco language=sql)+ kpiSqlConfigApi
- 冒烟:CONFIG_SQL驱动S5刷新(EngineType=CONFIG_SQL,值0对齐legacy)+ 无部署切换CONFIG_SQL↔LEGACY_CODE(仅activate配置)+ 安全校验4/4 + 401鉴权
- 不改 S5_L1_002/004;SQL安全单元测试24/24

chore: bump version Web 2.4.262 / server 1.0.278
YY968XX 1 Minggu lalu
induk
melakukan
eff067f08b

+ 1 - 1
Web/package.json

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

+ 53 - 0
Web/src/views/aidop/api/kpiSqlConfigApi.ts

@@ -0,0 +1,53 @@
+import service from '/@/utils/request';
+
+const BASE = '/api/AdoSmartOpsKpiCalcConfig';
+
+/** 某 KPI 的全部配置版本 */
+export function getByMetric(metricCode: string, moduleCode: string) {
+	return service({ url: `${BASE}/by-metric/${encodeURIComponent(metricCode)}`, method: 'get', params: { moduleCode } });
+}
+
+/** 已登记数据源 */
+export function getDataSources() {
+	return service({ url: `${BASE}/data-sources`, method: 'get' });
+}
+
+/** 新增草稿 */
+export function createDraft(data: any) {
+	return service({ url: BASE, method: 'post', data });
+}
+
+/** 编辑草稿 */
+export function updateDraft(id: number, data: any) {
+	return service({ url: `${BASE}/${id}`, method: 'put', data });
+}
+
+/** 删除草稿 */
+export function deleteConfig(id: number) {
+	return service({ url: `${BASE}/${id}`, method: 'delete' });
+}
+
+/** SQL 安全校验 */
+export function validateSql(sqlScript: string) {
+	return service({ url: `${BASE}/validate`, method: 'post', data: { sqlScript } });
+}
+
+/** 试算(不写正式值);真实执行可能数十秒,放大超时 */
+export function previewSql(data: any) {
+	return service({ url: `${BASE}/preview`, method: 'post', data, timeout: 90000 });
+}
+
+/** 发布 */
+export function publishConfig(id: number) {
+	return service({ url: `${BASE}/${id}/publish`, method: 'post' });
+}
+
+/** 激活/回滚(激活某已发布历史版本) */
+export function activateConfig(id: number) {
+	return service({ url: `${BASE}/${id}/activate`, method: 'post' });
+}
+
+/** 停用 */
+export function retireConfig(id: number) {
+	return service({ url: `${BASE}/${id}/retire`, method: 'post' });
+}

+ 71 - 0
Web/src/views/aidop/kanban/components/SqlEditor.vue

@@ -0,0 +1,71 @@
+<template>
+	<div ref="editorRef" class="sql-editor" :style="{ height: height }"></div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
+import * as monaco from 'monaco-editor';
+
+const props = withDefaults(
+	defineProps<{ modelValue: string; height?: string; readonly?: boolean }>(),
+	{ height: '320px', readonly: false }
+);
+const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>();
+
+const editorRef = ref<HTMLElement | null>(null);
+let editor: monaco.editor.IStandaloneCodeEditor | null = null;
+
+function init() {
+	if (editor || !editorRef.value) return;
+	editor = monaco.editor.create(editorRef.value, {
+		value: props.modelValue || '',
+		language: 'sql',
+		theme: 'vs-dark',
+		automaticLayout: true,
+		wordWrap: 'on',
+		minimap: { enabled: false },
+		readOnly: !!props.readonly,
+		scrollBeyondLastLine: false,
+		fontSize: 13,
+		tabSize: 2,
+	});
+	editor.onDidChangeModelContent(() => {
+		const v = editor!.getValue();
+		if (v !== props.modelValue) emit('update:modelValue', v);
+	});
+}
+
+onMounted(() => nextTick(() => setTimeout(init, 1)));
+onBeforeUnmount(() => {
+	editor?.dispose();
+	editor = null;
+});
+
+watch(
+	() => props.modelValue,
+	(v) => {
+		if (editor && editor.getValue() !== (v || '')) editor.setValue(v || '');
+	}
+);
+watch(
+	() => props.readonly,
+	(ro) => editor?.updateOptions({ readOnly: !!ro })
+);
+
+// tab 激活时(DOM 从隐藏变可见)父组件调用,确保初始化并重新布局
+defineExpose({
+	refresh: () => {
+		init();
+		nextTick(() => editor?.layout());
+	},
+});
+</script>
+
+<style scoped>
+.sql-editor {
+	width: 100%;
+	border: 1px solid #dcdfe6;
+	border-radius: 4px;
+	overflow: hidden;
+}
+</style>

+ 223 - 1
Web/src/views/aidop/kanban/kpiMaster.vue

@@ -277,12 +277,86 @@
 					</div>
 				</div>
 			</el-tab-pane>
+
+			<!-- ═══ Tab 3: 计算配置(SQL 配置化最小闭环) ═══ -->
+			<el-tab-pane label="计算配置" name="calc">
+				<div v-if="!currentDetail" class="calc-empty">请先在「指标管理」选择一个 KPI</div>
+				<div v-else class="calc-tab">
+					<div class="calc-tab__head">
+						<h3>{{ currentDetail.metricCode }} · {{ currentDetail.metricName }}</h3>
+						<span class="calc-engine-tag">当前生效引擎:{{ currentEngine }}</span>
+					</div>
+
+					<el-table :data="calcVersions" size="small" border style="margin-bottom: 12px">
+						<el-table-column prop="versionNo" label="版本" width="64" />
+						<el-table-column prop="calcEngineType" label="引擎" width="120" />
+						<el-table-column prop="publishStatus" label="状态" width="96" />
+						<el-table-column label="生效" width="64">
+							<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success" size="small">生效</el-tag></template>
+						</el-table-column>
+						<el-table-column prop="dataSourceCode" label="数据源" width="120" />
+						<el-table-column prop="publishedBy" label="发布人" width="110" />
+						<el-table-column label="操作" min-width="240">
+							<template #default="{ row }">
+								<el-button link size="small" @click="editDraft(row)">载入</el-button>
+								<el-button v-if="row.publishStatus === 'DRAFT'" link type="primary" size="small" @click="doPublish(row.id)">发布</el-button>
+								<el-button v-if="row.publishStatus === 'PUBLISHED' && !row.isCurrent" link type="warning" size="small" @click="doActivate(row.id)">激活</el-button>
+								<el-button v-if="row.publishStatus !== 'RETIRED'" link type="danger" size="small" @click="doRetire(row.id)">停用</el-button>
+							</template>
+						</el-table-column>
+					</el-table>
+
+					<el-form :inline="true" size="small" class="calc-form">
+						<el-form-item label="计算引擎">
+							<el-select v-model="calcDraft.calcEngineType" style="width: 150px">
+								<el-option label="CONFIG_SQL" value="CONFIG_SQL" />
+								<el-option label="LEGACY_CODE" value="LEGACY_CODE" />
+							</el-select>
+						</el-form-item>
+						<el-form-item label="数据源">
+							<el-select v-model="calcDraft.dataSourceCode" style="width: 220px">
+								<el-option v-for="ds in calcDataSources" :key="ds.code" :label="ds.name" :value="ds.code" />
+							</el-select>
+						</el-form-item>
+						<el-form-item label="超时(秒)">
+							<el-input-number v-model="calcDraft.timeoutSeconds" :min="1" :max="60" size="small" />
+						</el-form-item>
+						<el-button size="small" @click="newDraft">新建版本</el-button>
+					</el-form>
+
+					<div v-if="calcDraft.calcEngineType === 'CONFIG_SQL'">
+						<div class="calc-params">
+							可用参数:@tenant_id @factory_id @module_code @metric_code @biz_date @period_start @period_end @ztid
+							| 结果契约:metric_value(必需) numerator_value denominator_value result_status result_message | 只允许 SELECT/WITH,返回 0/1 行
+						</div>
+						<SqlEditor ref="sqlEditorRef" v-model="calcDraft.sqlScript" height="240px" />
+						<div class="calc-actions">
+							<el-button size="small" @click="doValidate">校验</el-button>
+							<el-button size="small" :loading="calcBusy" @click="doPreview">试算</el-button>
+							<el-button type="primary" size="small" :loading="calcBusy" @click="saveDraft">保存草稿</el-button>
+						</div>
+						<div v-if="calcValidation" class="calc-line" :class="calcValidation.ok ? 'ok' : 'err'">
+							校验:{{ calcValidation.validationStatus }} {{ calcValidation.errorMessage || '' }}
+							<span v-if="calcValidation.referencedTables && calcValidation.referencedTables.length">|表:{{ calcValidation.referencedTables.join(', ') }}</span>
+						</div>
+						<div v-if="calcPreview" class="calc-line" :class="calcPreview.ok ? 'ok' : 'err'">
+							试算:{{ calcPreview.resultStatus }} | 值={{ calcPreview.metricValue }} | 分子={{ calcPreview.numeratorValue }} | 行数={{ calcPreview.rowCount }} | 耗时={{ calcPreview.durationMs }}ms
+							<span v-if="calcPreview.errorMessage">|{{ calcPreview.errorCode }}: {{ calcPreview.errorMessage }}</span>
+							<div class="calc-preview-params">参数:{{ calcPreview.executedParameters }}</div>
+						</div>
+					</div>
+					<div v-else class="calc-legacy-note">
+						LEGACY_CODE:使用内置代码路径(BuildS5L1001)计算。保存草稿并发布/激活后,看板刷新走 legacy。
+						<div class="calc-actions"><el-button type="primary" size="small" @click="saveDraft">保存草稿</el-button></div>
+					</div>
+				</div>
+			</el-tab-pane>
 		</el-tabs>
 	</div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, onBeforeUnmount } from 'vue';
+import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
 import { useRouter } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import type { NodeDropType } from 'element-plus/es/components/tree/src/tree.type';
@@ -290,6 +364,8 @@ import type { UploadFile, UploadInstance } from 'element-plus';
 import * as api from '/@/views/aidop/api/kpiMasterApi';
 import type { KpiTreeNode, KpiDetail } from '/@/views/aidop/api/kpiMasterApi';
 import FormulaEditor from './components/FormulaEditor.vue';
+import SqlEditor from './components/SqlEditor.vue';
+import * as cfgApi from '/@/views/aidop/api/kpiSqlConfigApi';
 
 const router = useRouter();
 
@@ -437,6 +513,152 @@ async function onNodeClick(data: KpiTreeNode) {
 	}
 }
 
+// ═══ 计算配置(SQL 配置化,最小闭环) ═══
+const sqlEditorRef = ref<any>(null);
+const calcVersions = ref<any[]>([]);
+const calcDataSources = ref<any[]>([]);
+const calcBusy = ref(false);
+const calcValidation = ref<any>(null);
+const calcPreview = ref<any>(null);
+const calcDraft = reactive({
+	id: 0,
+	calcEngineType: 'CONFIG_SQL',
+	dataSourceCode: 'MDP_LOCAL',
+	sqlScript: '',
+	timeoutSeconds: 30,
+	remark: '',
+});
+const currentEngine = computed(() => calcVersions.value.find((v) => v.isCurrent)?.calcEngineType ?? 'LEGACY_CODE(无配置,默认)');
+const PILOT_SQL_TEMPLATE = `SELECT AVG(cycle_days) AS metric_value,
+       COUNT(*) AS numerator_value
+FROM dwd_t8_material_online_cycle
+WHERE tenant_id = @tenant_id AND factory_id = @factory_id
+  AND biz_date = @biz_date AND source_ztid = @ztid
+  AND cycle_days IS NOT NULL`;
+
+function unwrapRes(res: any) {
+	return (res as any)?.data ?? res;
+}
+
+function newDraft() {
+	calcDraft.id = 0;
+	calcDraft.calcEngineType = 'CONFIG_SQL';
+	calcDraft.dataSourceCode = 'MDP_LOCAL';
+	calcDraft.sqlScript = PILOT_SQL_TEMPLATE;
+	calcDraft.timeoutSeconds = 30;
+	calcDraft.remark = '';
+	calcValidation.value = null;
+	calcPreview.value = null;
+}
+
+function editDraft(row: any) {
+	calcDraft.id = row.publishStatus === 'DRAFT' ? row.id : 0; // 非草稿载入为新草稿
+	calcDraft.calcEngineType = row.calcEngineType;
+	calcDraft.dataSourceCode = row.dataSourceCode || 'MDP_LOCAL';
+	calcDraft.sqlScript = row.sqlScript || '';
+	calcDraft.timeoutSeconds = row.timeoutSeconds || 30;
+	calcDraft.remark = row.remark || '';
+	calcValidation.value = null;
+	calcPreview.value = null;
+}
+
+async function loadCalc() {
+	if (!currentDetail.value) return;
+	try {
+		calcDataSources.value = unwrapRes(await cfgApi.getDataSources()) || [];
+		calcVersions.value = unwrapRes(await cfgApi.getByMetric(currentDetail.value.metricCode, currentDetail.value.moduleCode)) || [];
+		if (!calcDraft.sqlScript && calcDraft.id === 0) newDraft();
+	} catch (e: any) {
+		ElMessage.error('加载计算配置失败: ' + (e.response?.data?.message || e.message || e));
+	}
+}
+
+async function doValidate() {
+	calcValidation.value = unwrapRes(await cfgApi.validateSql(calcDraft.sqlScript));
+}
+
+async function doPreview() {
+	if (!currentDetail.value) return;
+	calcBusy.value = true;
+	try {
+		calcPreview.value = unwrapRes(
+			await cfgApi.previewSql({
+				metricCode: currentDetail.value.metricCode,
+				moduleCode: currentDetail.value.moduleCode,
+				dataSourceCode: calcDraft.dataSourceCode,
+				sqlScript: calcDraft.sqlScript,
+				timeoutSeconds: calcDraft.timeoutSeconds,
+			})
+		);
+	} catch (e: any) {
+		ElMessage.error('试算失败: ' + (e.response?.data?.message || e.message || e));
+	} finally {
+		calcBusy.value = false;
+	}
+}
+
+async function saveDraft() {
+	if (!currentDetail.value) return;
+	calcBusy.value = true;
+	try {
+		const payload = {
+			metricCode: currentDetail.value.metricCode,
+			moduleCode: currentDetail.value.moduleCode,
+			calcEngineType: calcDraft.calcEngineType,
+			dataSourceCode: calcDraft.dataSourceCode,
+			sqlScript: calcDraft.sqlScript,
+			timeoutSeconds: calcDraft.timeoutSeconds,
+			remark: calcDraft.remark,
+		};
+		if (calcDraft.id > 0) await cfgApi.updateDraft(calcDraft.id, payload);
+		else {
+			const created = unwrapRes(await cfgApi.createDraft(payload));
+			calcDraft.id = created?.id || 0;
+		}
+		ElMessage.success('草稿已保存');
+		await loadCalc();
+	} catch (e: any) {
+		ElMessage.error('保存失败: ' + (e.response?.data?.message || e.message || e));
+	} finally {
+		calcBusy.value = false;
+	}
+}
+
+async function doPublish(id: number) {
+	try {
+		await cfgApi.publishConfig(id);
+		ElMessage.success('已发布');
+		await loadCalc();
+	} catch (e: any) {
+		ElMessage.error('发布失败: ' + (e.response?.data?.message || e.message || e));
+	}
+}
+async function doActivate(id: number) {
+	try {
+		await cfgApi.activateConfig(id);
+		ElMessage.success('已激活该版本(下次刷新生效)');
+		await loadCalc();
+	} catch (e: any) {
+		ElMessage.error('激活失败: ' + (e.response?.data?.message || e.message || e));
+	}
+}
+async function doRetire(id: number) {
+	try {
+		await cfgApi.retireConfig(id);
+		ElMessage.success('已停用');
+		await loadCalc();
+	} catch (e: any) {
+		ElMessage.error('停用失败: ' + (e.response?.data?.message || e.message || e));
+	}
+}
+
+watch([activeTab, () => currentDetail.value?.id], ([t]) => {
+	if (t === 'calc') {
+		loadCalc();
+		nextTick(() => sqlEditorRef.value?.refresh());
+	}
+});
+
 async function onSave() {
 	if (!currentDetail.value) return;
 	saving.value = true;

+ 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.277</AssemblyVersion>
-    <FileVersion>1.0.277</FileVersion>
-    <Version>1.0.277</Version>
+    <AssemblyVersion>1.0.278</AssemblyVersion>
+    <FileVersion>1.0.278</FileVersion>
+    <Version>1.0.278</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -247,6 +247,9 @@
     <None Update="UpdateScripts\1.0.277.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.278.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 71 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.278.sql

@@ -0,0 +1,71 @@
+-- 1.0.278:KPI SQL 配置化计算引擎(AIDOP-KPI-SQL-CONFIG-RUNTIME-1)。
+-- 表结构由 CodeFirst(EnableInitTable=true)从实体创建;本项目 EnableUnderLine=false → 列名 PascalCase。
+-- 下方 CREATE TABLE IF NOT EXISTS 为兜底(CodeFirst 已建则跳过),列名/类型对齐实体。
+-- 种子:S5_L1_001 在 AIDOP 租户 797403760988229 的 LEGACY_CODE v1(PUBLISHED/IsCurrent=1)——默认=legacy 向后兼容 + 支持"回滚到 LEGACY_CODE"演示。
+
+CREATE TABLE IF NOT EXISTS ado_smart_ops_kpi_calc_config (
+  `Id` BIGINT NOT NULL AUTO_INCREMENT,
+  `TenantId` BIGINT DEFAULT NULL,
+  `MetricCode` VARCHAR(50) NOT NULL,
+  `ModuleCode` VARCHAR(20) NOT NULL,
+  `VersionNo` INT NOT NULL,
+  `CalcEngineType` VARCHAR(20) NOT NULL DEFAULT 'CONFIG_SQL',
+  `DataSourceCode` VARCHAR(100) DEFAULT NULL,
+  `SqlScript` TEXT DEFAULT NULL,
+  `SqlParametersJson` TEXT DEFAULT NULL,
+  `TimeoutSeconds` INT NOT NULL DEFAULT 30,
+  `PublishStatus` VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+  `IsCurrent` TINYINT(1) NOT NULL DEFAULT 0,
+  `Remark` TEXT DEFAULT NULL,
+  `CreatedBy` VARCHAR(100) DEFAULT NULL,
+  `CreatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `UpdatedBy` VARCHAR(100) DEFAULT NULL,
+  `UpdatedAt` DATETIME DEFAULT NULL,
+  `PublishedBy` VARCHAR(100) DEFAULT NULL,
+  `PublishedAt` DATETIME DEFAULT NULL,
+  `RetiredBy` VARCHAR(100) DEFAULT NULL,
+  `RetiredAt` DATETIME DEFAULT NULL,
+  PRIMARY KEY (`Id`),
+  UNIQUE KEY `uk_kpi_calc_cfg_metric_ver` (`MetricCode`, `TenantId`, `VersionNo`),
+  KEY `idx_kpi_calc_cfg_current` (`MetricCode`, `TenantId`, `IsCurrent`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='KPI 计算配置(SQL 配置化)';
+
+CREATE TABLE IF NOT EXISTS ado_smart_ops_kpi_calc_run_log (
+  `Id` BIGINT NOT NULL AUTO_INCREMENT,
+  `TenantId` BIGINT DEFAULT NULL,
+  `BatchId` VARCHAR(100) NOT NULL,
+  `ModuleCode` VARCHAR(20) NOT NULL,
+  `MetricCode` VARCHAR(50) NOT NULL,
+  `ConfigId` BIGINT DEFAULT NULL,
+  `VersionNo` INT DEFAULT NULL,
+  `EngineType` VARCHAR(20) NOT NULL,
+  `DataSourceCode` VARCHAR(100) DEFAULT NULL,
+  `BizDate` DATE NOT NULL,
+  `StartedAt` DATETIME NOT NULL,
+  `FinishedAt` DATETIME DEFAULT NULL,
+  `DurationMs` BIGINT NOT NULL DEFAULT 0,
+  `Status` VARCHAR(20) NOT NULL,
+  `RowCount` INT NOT NULL DEFAULT 0,
+  `MetricValue` DECIMAL(18,4) DEFAULT NULL,
+  `NumeratorValue` DECIMAL(18,4) DEFAULT NULL,
+  `DenominatorValue` DECIMAL(18,4) DEFAULT NULL,
+  `ErrorCode` VARCHAR(50) DEFAULT NULL,
+  `ErrorMessage` VARCHAR(500) DEFAULT NULL,
+  `SqlHash` VARCHAR(64) DEFAULT NULL,
+  `ParameterSnapshot` TEXT DEFAULT NULL,
+  `TriggerType` VARCHAR(20) NOT NULL,
+  `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`Id`),
+  UNIQUE KEY `uk_kpi_calc_run_metric_batch` (`MetricCode`, `BatchId`),
+  KEY `idx_kpi_calc_run_metric_time` (`MetricCode`, `StartedAt`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='KPI 计算 SQL 运行日志';
+
+-- 种子 S5_L1_001 LEGACY_CODE v1(幂等:存在则跳过)。列名 PascalCase(EnableUnderLine=false)。
+INSERT INTO ado_smart_ops_kpi_calc_config
+  (`TenantId`, `MetricCode`, `ModuleCode`, `VersionNo`, `CalcEngineType`, `TimeoutSeconds`, `PublishStatus`, `IsCurrent`, `Remark`, `CreatedBy`, `CreatedAt`)
+SELECT 797403760988229, 'S5_L1_001', 'S5', 1, 'LEGACY_CODE', 30, 'PUBLISHED', 1, 'S5_L1_001 初始 legacy 版本(向后兼容+回滚基线)', 'system', NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM ado_smart_ops_kpi_calc_config
+  WHERE `MetricCode`='S5_L1_001' AND `TenantId`=797403760988229 AND `VersionNo`=1
+);

+ 115 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/SmartOps/KpiSqlSecurityValidatorTests.cs

@@ -0,0 +1,115 @@
+using Admin.NET.Plugin.AiDOP.SmartOps;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.SmartOps;
+
+/// <summary>KPI 配置 SQL 安全校验(多层非正则)单元测试。覆盖合法查询 + 各类写/危险/绕过。</summary>
+public class KpiSqlSecurityValidatorTests
+{
+    private const string LegalSelect =
+        "SELECT AVG(cycle_days) AS metric_value FROM dwd_t8_material_online_cycle WHERE tenant_id=@tenant_id AND cycle_days IS NOT NULL";
+
+    [Fact]
+    public void LegalSelect_Valid()
+    {
+        var r = KpiSqlSecurityValidator.Validate(LegalSelect);
+        Assert.True(r.Ok);
+        Assert.Contains("dwd_t8_material_online_cycle", r.ReferencedTables);
+    }
+
+    [Fact]
+    public void LegalWithSelect_Valid()
+    {
+        var sql = "WITH t AS (SELECT cycle_days FROM dwd_t8_material_online_cycle WHERE tenant_id=@tenant_id) " +
+                  "SELECT AVG(cycle_days) AS metric_value FROM mdp_std_t8_kc_tz_head";
+        var r = KpiSqlSecurityValidator.Validate(sql);
+        Assert.True(r.Ok);
+    }
+
+    [Theory]
+    [InlineData("INSERT INTO dwd_t8_x(a) VALUES(1)")]
+    [InlineData("UPDATE dwd_t8_x SET a=1")]
+    [InlineData("DELETE FROM dwd_t8_x")]
+    [InlineData("DROP TABLE dwd_t8_x")]
+    [InlineData("ALTER TABLE dwd_t8_x ADD c INT")]
+    [InlineData("CREATE TABLE t(a INT)")]
+    [InlineData("TRUNCATE TABLE dwd_t8_x")]
+    [InlineData("REPLACE INTO dwd_t8_x VALUES(1)")]
+    [InlineData("MERGE INTO dwd_t8_x")]
+    public void WriteStatements_Invalid(string sql)
+    {
+        var r = KpiSqlSecurityValidator.Validate(sql);
+        Assert.False(r.Ok);
+    }
+
+    [Theory]
+    [InlineData("EXEC sp_x")]
+    [InlineData("EXECUTE sp_x")]
+    [InlineData("CALL my_proc()")]
+    public void ProcCalls_Invalid(string sql)
+    {
+        Assert.False(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void MultiStatement_Invalid()
+    {
+        var r = KpiSqlSecurityValidator.Validate("SELECT 1 AS metric_value FROM dwd_t8_x; SELECT 2");
+        Assert.False(r.Ok);
+        Assert.Equal("MULTI_STATEMENT", r.ErrorCode);
+    }
+
+    [Fact]
+    public void CommentHiddenSecondStatement_StrippedThenSingleStatement_Valid()
+    {
+        // 注释里的第二语句被去掉后应为单条合法 SELECT
+        var sql = "SELECT AVG(cycle_days) AS metric_value FROM dwd_t8_material_online_cycle -- ; DROP TABLE x";
+        Assert.True(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void CommentHiddenKeyword_NotFalsePositive()
+    {
+        var sql = "SELECT AVG(cycle_days) AS metric_value /* delete update drop */ FROM dwd_t8_material_online_cycle";
+        Assert.True(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void KeywordInsideStringLiteral_NotFalsePositive()
+    {
+        var sql = "SELECT 'DROP TABLE users' AS result_message, 1 AS metric_value FROM dwd_t8_material_online_cycle";
+        Assert.True(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void IllegalTable_Invalid()
+    {
+        var r = KpiSqlSecurityValidator.Validate("SELECT metric_value FROM sys_user WHERE tenant_id=@tenant_id");
+        Assert.False(r.Ok);
+        Assert.Equal("TABLE_NOT_ALLOWED", r.ErrorCode);
+    }
+
+    [Theory]
+    [InlineData("SHOW TABLES")]
+    [InlineData("SET @a=1")]
+    [InlineData("USE mysql")]
+    public void NonSelectStart_Invalid(string sql)
+    {
+        Assert.False(KpiSqlSecurityValidator.Validate(sql).Ok);
+    }
+
+    [Fact]
+    public void IntoOutfile_Invalid()
+    {
+        var r = KpiSqlSecurityValidator.Validate(
+            "SELECT metric_value INTO OUTFILE '/tmp/x' FROM dwd_t8_material_online_cycle");
+        Assert.False(r.Ok);
+    }
+
+    [Fact]
+    public void Empty_Invalid()
+    {
+        Assert.False(KpiSqlSecurityValidator.Validate("   ").Ok);
+        Assert.False(KpiSqlSecurityValidator.Validate("-- only comment").Ok);
+    }
+}

+ 105 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiCalcConfigController.cs

@@ -0,0 +1,105 @@
+using System.Security.Claims;
+using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+using Admin.NET.Plugin.AiDOP.SmartOps;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+/// <summary>
+/// KPI 计算配置(SQL 配置化)接口。**要求登录**(区别于其它 AiDOP 匿名接口)——SQL 编辑/发布是高危面。
+/// tenant/权限不信任前端:租户由服务端按 moduleCode 解析;操作人取自 JWT。
+/// </summary>
+[ApiController]
+[Route("api/[controller]")]
+[Authorize]
+public class AdoSmartOpsKpiCalcConfigController : ControllerBase
+{
+    private readonly AdoSmartOpsKpiCalcConfigService _service;
+
+    public AdoSmartOpsKpiCalcConfigController(AdoSmartOpsKpiCalcConfigService service)
+    {
+        _service = service;
+    }
+
+    private string Operator() =>
+        User?.FindFirstValue("RealName") ?? User?.FindFirstValue("Account") ?? User?.Identity?.Name ?? "unknown";
+
+    /// <summary>某 KPI 的全部配置版本。</summary>
+    [HttpGet("by-metric/{metricCode}")]
+    public async Task<IActionResult> GetByMetric(string metricCode, [FromQuery] string moduleCode)
+    {
+        if (string.IsNullOrWhiteSpace(moduleCode))
+            return BadRequest(new { message = "moduleCode 必填" });
+        var list = await _service.GetByMetricAsync(metricCode, moduleCode);
+        return Ok(list);
+    }
+
+    /// <summary>已登记数据源列表。</summary>
+    [HttpGet("data-sources")]
+    public IActionResult DataSources() => Ok(_service.ListDataSources());
+
+    /// <summary>新增草稿。</summary>
+    [HttpPost]
+    public async Task<IActionResult> Create([FromBody] KpiCalcConfigUpsertDto dto)
+    {
+        if (string.IsNullOrWhiteSpace(dto.MetricCode) || string.IsNullOrWhiteSpace(dto.ModuleCode))
+            return BadRequest(new { message = "metricCode / moduleCode 必填" });
+        var created = await _service.CreateDraftAsync(dto, Operator());
+        return Ok(created);
+    }
+
+    /// <summary>编辑草稿。</summary>
+    [HttpPut("{id:long}")]
+    public async Task<IActionResult> Update(long id, [FromBody] KpiCalcConfigUpsertDto dto)
+    {
+        await _service.UpdateDraftAsync(id, dto, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>删除草稿。</summary>
+    [HttpDelete("{id:long}")]
+    public async Task<IActionResult> Delete(long id)
+    {
+        await _service.DeleteAsync(id);
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>SQL 安全校验。</summary>
+    [HttpPost("validate")]
+    public IActionResult Validate([FromBody] KpiSqlValidateDto dto) => Ok(_service.Validate(dto.SqlScript));
+
+    /// <summary>试算(不写正式 KPI 值;服务端重校验 + 只读执行)。</summary>
+    [HttpPost("preview")]
+    public async Task<IActionResult> Preview([FromBody] KpiSqlPreviewDto dto, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(dto.MetricCode) || string.IsNullOrWhiteSpace(dto.ModuleCode))
+            return BadRequest(new { message = "metricCode / moduleCode 必填" });
+        var res = await _service.PreviewAsync(dto, ct);
+        return Ok(res);
+    }
+
+    /// <summary>发布(事务:单生效版本)。</summary>
+    [HttpPost("{id:long}/publish")]
+    public async Task<IActionResult> Publish(long id)
+    {
+        await _service.PublishAsync(id, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>激活/回滚(把某已发布历史版本设为当前生效)。</summary>
+    [HttpPost("{id:long}/activate")]
+    public async Task<IActionResult> Activate(long id)
+    {
+        await _service.ActivateAsync(id, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>停用。</summary>
+    [HttpPost("{id:long}/retire")]
+    public async Task<IActionResult> Retire(long id)
+    {
+        await _service.RetireAsync(id, Operator());
+        return Ok(new { ok = true });
+    }
+}

+ 84 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/SmartOps/AdoSmartOpsKpiCalcConfigDtos.cs

@@ -0,0 +1,84 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+
+/// <summary>新增/编辑草稿。</summary>
+public sealed class KpiCalcConfigUpsertDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public string CalcEngineType { get; set; } = "CONFIG_SQL";
+    public string? DataSourceCode { get; set; }
+    public string? SqlScript { get; set; }
+    public string? SqlParametersJson { get; set; }
+    public int TimeoutSeconds { get; set; } = 30;
+    public string? Remark { get; set; }
+}
+
+/// <summary>配置版本输出。</summary>
+public sealed class KpiCalcConfigDto
+{
+    public long Id { get; set; }
+    public long? TenantId { get; set; }
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public int VersionNo { get; set; }
+    public string CalcEngineType { get; set; } = string.Empty;
+    public string? DataSourceCode { get; set; }
+    public string? SqlScript { get; set; }
+    public string? SqlParametersJson { get; set; }
+    public int TimeoutSeconds { get; set; }
+    public string PublishStatus { get; set; } = string.Empty;
+    public bool IsCurrent { get; set; }
+    public string? Remark { get; set; }
+    public string? CreatedBy { get; set; }
+    public DateTime CreatedAt { get; set; }
+    public string? PublishedBy { get; set; }
+    public DateTime? PublishedAt { get; set; }
+}
+
+/// <summary>SQL 校验入参。</summary>
+public sealed class KpiSqlValidateDto
+{
+    public string? SqlScript { get; set; }
+}
+
+/// <summary>SQL 校验结果。</summary>
+public sealed class KpiSqlValidateResultDto
+{
+    public bool Ok { get; set; }
+    public string ValidationStatus { get; set; } = string.Empty; // VALID / INVALID
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+    public List<string> ReferencedTables { get; set; } = new();
+}
+
+/// <summary>试算入参(服务端重读/重校验,不信任前端 tenant/权限)。</summary>
+public sealed class KpiSqlPreviewDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public string? DataSourceCode { get; set; }
+    public string? SqlScript { get; set; }
+    public int TimeoutSeconds { get; set; } = 20;
+    public DateTime? BizDate { get; set; }
+}
+
+/// <summary>试算结果(不写正式 KPI 值)。</summary>
+public sealed class KpiSqlPreviewResultDto
+{
+    public bool Ok { get; set; }
+    public string ValidationStatus { get; set; } = string.Empty;
+    public string MetricCode { get; set; } = string.Empty;
+    public string EngineType { get; set; } = "CONFIG_SQL";
+    public string? DataSourceCode { get; set; }
+    public string BizDate { get; set; } = string.Empty;
+    public long DurationMs { get; set; }
+    public int RowCount { get; set; }
+    public decimal? MetricValue { get; set; }
+    public decimal? NumeratorValue { get; set; }
+    public decimal? DenominatorValue { get; set; }
+    public string ResultStatus { get; set; } = string.Empty; // SUCCESS / NO_DATA / FAILED
+    public string? ResultMessage { get; set; }
+    public string? ExecutedParameters { get; set; }
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+}

+ 77 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiCalcConfig.cs

@@ -0,0 +1,77 @@
+using Admin.NET.Core;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// KPI 计算配置(SQL 配置化引擎)。一 KPI 多版本,含发布状态机与审计。
+/// 独立于 ado_smart_ops_kpi_master(看板热路径),SQL 敏感配置单独承载。
+/// 同一 (TenantId, MetricCode) 仅一个 IsCurrent=1 的生效版本,由服务层事务保证。
+/// </summary>
+[SugarTable("ado_smart_ops_kpi_calc_config", "KPI 计算配置(SQL 配置化)")]
+[SugarIndex("uk_kpi_calc_cfg_metric_ver", nameof(MetricCode), OrderByType.Asc, nameof(TenantId), OrderByType.Asc, nameof(VersionNo), OrderByType.Asc, true)]
+public class AdoSmartOpsKpiCalcConfig : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 ID", ColumnDataType = "bigint", IsNullable = true)]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "指标编码 如 S5_L1_001", Length = 50)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "模块 S1~S9", Length = 20)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "版本号(同 KPI 内递增)")]
+    public int VersionNo { get; set; }
+
+    [SugarColumn(ColumnDescription = "计算引擎 LEGACY_CODE / CONFIG_SQL / LEGACY_TVF", Length = 20)]
+    public string CalcEngineType { get; set; } = "CONFIG_SQL";
+
+    [SugarColumn(ColumnDescription = "数据源编码(已登记数据源,禁连接串)", Length = 100, IsNullable = true)]
+    public string? DataSourceCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "只读 SQL 脚本", ColumnDataType = "text", IsNullable = true)]
+    public string? SqlScript { get; set; }
+
+    [SugarColumn(ColumnDescription = "参数声明 JSON(元数据,运行绑定由引擎注入)", ColumnDataType = "text", IsNullable = true)]
+    public string? SqlParametersJson { get; set; }
+
+    [SugarColumn(ColumnDescription = "查询超时(秒),受系统上限约束")]
+    public int TimeoutSeconds { get; set; } = 30;
+
+    [SugarColumn(ColumnDescription = "发布状态 DRAFT / PUBLISHED / RETIRED", Length = 20)]
+    public string PublishStatus { get; set; } = "DRAFT";
+
+    [SugarColumn(ColumnDescription = "是否当前生效版本(同 KPI 唯一)")]
+    public bool IsCurrent { get; set; }
+
+    [SugarColumn(ColumnDescription = "备注", ColumnDataType = "text", IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建人", Length = 100, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreatedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "更新人", Length = 100, IsNullable = true)]
+    public string? UpdatedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "更新时间", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "发布人", Length = 100, IsNullable = true)]
+    public string? PublishedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "发布时间", IsNullable = true)]
+    public DateTime? PublishedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "停用人", Length = 100, IsNullable = true)]
+    public string? RetiredBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "停用时间", IsNullable = true)]
+    public DateTime? RetiredAt { get; set; }
+}

+ 87 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiCalcRunLog.cs

@@ -0,0 +1,87 @@
+using Admin.NET.Core;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// KPI 计算 SQL 执行运行日志(单 KPI 粒度,含配置版本 + SQL 指纹 + 分子分母)。
+/// mdp_transform_run_log 是批次级、表达不了 per-KPI + config version,故独立此表。
+/// 只记 SqlHash + ConfigId,不落 SQL 明文(SQL 原文在受权限控制的配置表)。
+/// </summary>
+[SugarTable("ado_smart_ops_kpi_calc_run_log", "KPI 计算 SQL 运行日志")]
+[SugarIndex("uk_kpi_calc_run_metric_batch", nameof(MetricCode), OrderByType.Asc, nameof(BatchId), OrderByType.Asc, true)]
+[SugarIndex("idx_kpi_calc_run_metric_time", nameof(MetricCode), OrderByType.Asc, nameof(StartedAt), OrderByType.Desc, false)]
+public class AdoSmartOpsKpiCalcRunLog : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 ID", ColumnDataType = "bigint", IsNullable = true)]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "批次 ID(关联 mdp_transform_run_log.batch_id)", Length = 100)]
+    public string BatchId { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "模块 S1~S9", Length = 20)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "指标编码", Length = 50)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "计算配置 ID(LEGACY 无配置时可空)", ColumnDataType = "bigint", IsNullable = true)]
+    public long? ConfigId { get; set; }
+
+    [SugarColumn(ColumnDescription = "配置版本号", IsNullable = true)]
+    public int? VersionNo { get; set; }
+
+    [SugarColumn(ColumnDescription = "引擎 LEGACY_CODE / CONFIG_SQL / LEGACY_TVF", Length = 20)]
+    public string EngineType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "数据源编码", Length = 100, IsNullable = true)]
+    public string? DataSourceCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "业务日期", ColumnDataType = "date")]
+    public DateTime BizDate { get; set; }
+
+    [SugarColumn(ColumnDescription = "开始时间")]
+    public DateTime StartedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "结束时间", IsNullable = true)]
+    public DateTime? FinishedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "耗时毫秒")]
+    public long DurationMs { get; set; }
+
+    [SugarColumn(ColumnDescription = "状态 SUCCESS / NO_DATA / FAILED", Length = 20)]
+    public string Status { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "返回行数")]
+    public int RowCount { get; set; }
+
+    [SugarColumn(ColumnDescription = "指标值", ColumnDataType = "decimal(18,4)", IsNullable = true)]
+    public decimal? MetricValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "分子", ColumnDataType = "decimal(18,4)", IsNullable = true)]
+    public decimal? NumeratorValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "分母", ColumnDataType = "decimal(18,4)", IsNullable = true)]
+    public decimal? DenominatorValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "错误码", Length = 50, IsNullable = true)]
+    public string? ErrorCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "错误摘要", Length = 500, IsNullable = true)]
+    public string? ErrorMessage { get; set; }
+
+    [SugarColumn(ColumnDescription = "SQL 指纹 SHA256(不落明文)", Length = 64, IsNullable = true)]
+    public string? SqlHash { get; set; }
+
+    [SugarColumn(ColumnDescription = "参数快照 JSON", ColumnDataType = "text", IsNullable = true)]
+    public string? ParameterSnapshot { get; set; }
+
+    [SugarColumn(ColumnDescription = "触发类型 AUTO / MANUAL / PREVIEW", Length = 20)]
+    public string TriggerType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreateTime { get; set; }
+}

+ 22 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs

@@ -30,14 +30,18 @@ public class S5MdpSyncTransformService : ITransient
     private const long NoticeReceiverUserId = 1300000000101L;
     private const string NoticeReceiverUserName = "超级管理员";
 
+    private readonly SmartOps.KpiCalcDispatcher _kpiCalcDispatcher;
+
     public S5MdpSyncTransformService(
         ISqlSugarClient db,
         SysNoticeService sysNoticeService,
-        ILogger<S5MdpSyncTransformService> logger)
+        ILogger<S5MdpSyncTransformService> logger,
+        SmartOps.KpiCalcDispatcher kpiCalcDispatcher)
     {
         _db = db;
         _sysNoticeService = sysNoticeService;
         _logger = logger;
+        _kpiCalcDispatcher = kpiCalcDispatcher;
     }
 
     public async Task<S5MdpSyncTransformResult> RunFullAsync(
@@ -76,7 +80,7 @@ public class S5MdpSyncTransformService : ITransient
             result.StageRows = 0;
             result.StandardRows = 0;
 
-            var sub16 = await BuildS5L1001MaterialOnlineCycleAsync(batchId, now, option, cancellationToken);
+            var sub16 = await BuildS5L1001MaterialOnlineCycleAsync(batchId, now, option, normalizedTrigger, cancellationToken);
             result.MergeSub("S5_L1_001", sub16);
 
             var sub17 = await BuildS5L1002MaterialOnlineFulfillmentAsync(batchId, now, option, cancellationToken);
@@ -102,9 +106,10 @@ public class S5MdpSyncTransformService : ITransient
     // KPI 实现(方老师 v5.4 KPI J 列 SQL 原逻辑直发 T8)
     // ─────────────────────────────────────────────────────────────────────────
 
-    /// <summary>S5_L1_001 物料上线周期 = 配送到产线日期(lbs=生产领料) - 收货日期(lbs=采购入库)。</summary>
+    /// <summary>S5_L1_001 物料上线周期 = 配送到产线日期(lbs=生产领料) - 收货日期(lbs=采购入库)。
+    /// 数据准备(写 dwd 明细)始终执行;最终 KPI 聚合由计算配置分发器接管(LEGACY_CODE/CONFIG_SQL)。</summary>
     private async Task<KpiBuildSubResult> BuildS5L1001MaterialOnlineCycleAsync(
-        string batchId, DateTime now, S5MdpRefreshOption option, CancellationToken ct)
+        string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
     {
         var sub = new KpiBuildSubResult();
 
@@ -170,12 +175,19 @@ ON DUPLICATE KEY UPDATE
         }
         sub.DwdRows = dwdAffected;
 
-        // KPI 值:所有物料 cycle_days 的算术平均;没有任一可计算物料时写 NULL,不伪装 0
-        decimal? metricValue = cycleDaysList.Count > 0
-            ? (decimal)cycleDaysList.Average()
-            : null;
-        sub.KpiRows = await UpsertKpiValueAsync("S5_L1_001", option.BizDate, metricValue, now, option);
-        sub.DenominatorStatus = cycleDaysList.Count > 0 ? "OK" : "NO_NUMERATOR";
+        // 数据准备(dwd 明细)已完成。最终 KPI 聚合交计算配置分发器:
+        //   无配置/LEGACY_CODE → 用下面 legacy 均值;CONFIG_SQL → 执行已发布只读 SQL;
+        //   CONFIG_SQL 失败不 fallback、不写值、保留上一成功值(ShouldUpsert=false)。
+        decimal? legacyValue = cycleDaysList.Count > 0 ? (decimal)cycleDaysList.Average() : null;
+        var legacyDenom = cycleDaysList.Count > 0 ? "OK" : "NO_NUMERATOR";
+        var dispatch = await _kpiCalcDispatcher.DispatchAsync(
+            "S5_L1_001", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
+            option.BizDate, option.DailyPeriodStart, option.DailyPeriodEnd, option.SourceZtid,
+            batchId, triggerType, legacyValue, legacyDenom, ct);
+        sub.KpiRows = dispatch.ShouldUpsert
+            ? await UpsertKpiValueAsync("S5_L1_001", option.BizDate, dispatch.MetricValue, now, option)
+            : 0;
+        sub.DenominatorStatus = dispatch.DenominatorStatus;
         return sub;
     }
 

+ 287 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiCalcConfigService.cs

@@ -0,0 +1,287 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// KPI 计算配置服务:CRUD + 校验 + 试算 + 发布/停用/激活(发布状态机,单生效版本事务保证)。
+/// 租户显式控制(ClearFilter):配置落在"分发器运行时查找的租户"= KPI 数据租户(S5=AidopSourceTenantMap 解析),
+/// 而非 API 调用者 JWT 租户;否则运行时找不到配置。SqlScript 属高危配置,接口须登录鉴权(Controller 层)。
+/// </summary>
+public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
+{
+    private const string EngineLegacyCode = "LEGACY_CODE";
+    private const string EngineConfigSql = "CONFIG_SQL";
+    private const string EngineLegacyTvf = "LEGACY_TVF";
+    private const string StatusDraft = "DRAFT";
+    private const string StatusPublished = "PUBLISHED";
+    private const string StatusRetired = "RETIRED";
+    private static readonly string[] TenantScopedModules = { "S5", "S6", "S7" };
+
+    private readonly ISqlSugarClient _db;
+    private readonly KpiSqlReadOnlyExecutor _executor;
+
+    public AdoSmartOpsKpiCalcConfigService(ISqlSugarClient db, KpiSqlReadOnlyExecutor executor)
+    {
+        _db = db;
+        _executor = executor;
+    }
+
+    /// <summary>解析配置应归属的租户(= 运行时 KPI 落库租户)。S5/S6/S7 走 T8 账套映射(pbxfxp→AIDOP)。</summary>
+    public static long ResolveKpiTenantId(string moduleCode)
+    {
+        if (TenantScopedModules.Contains((moduleCode ?? "").Trim().ToUpperInvariant()))
+            return AidopSourceTenantMap.ResolveTenantId("pbxfxp");
+        return SqlSugarConst.DefaultTenantId;
+    }
+
+    private ISugarQueryable<AdoSmartOpsKpiCalcConfig> Query() =>
+        _db.Queryable<AdoSmartOpsKpiCalcConfig>().ClearFilter<ITenantIdFilter>();
+
+    /// <summary>某 KPI 的全部版本(按版本号倒序)。</summary>
+    public async Task<List<KpiCalcConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
+    {
+        var tenantId = ResolveKpiTenantId(moduleCode);
+        var list = await Query()
+            .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId)
+            .OrderBy(x => x.VersionNo, OrderByType.Desc)
+            .ToListAsync();
+        return list.Select(ToDto).ToList();
+    }
+
+    /// <summary>当前生效配置(PUBLISHED + IsCurrent),无则 null(= 走 legacy)。</summary>
+    public async Task<AdoSmartOpsKpiCalcConfig?> GetActiveAsync(long tenantId, string metricCode)
+    {
+        return await Query()
+            .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId
+                        && x.IsCurrent && x.PublishStatus == StatusPublished)
+            .FirstAsync();
+    }
+
+    /// <summary>新增草稿(VersionNo = 现有最大 + 1)。</summary>
+    public async Task<KpiCalcConfigDto> CreateDraftAsync(KpiCalcConfigUpsertDto dto, string operatorName)
+    {
+        ValidateEngineType(dto.CalcEngineType);
+        var tenantId = ResolveKpiTenantId(dto.ModuleCode);
+        var maxVer = await Query()
+            .Where(x => x.MetricCode == dto.MetricCode && x.TenantId == tenantId)
+            .MaxAsync(x => (int?)x.VersionNo) ?? 0;
+
+        var entity = new AdoSmartOpsKpiCalcConfig
+        {
+            TenantId = tenantId,
+            MetricCode = dto.MetricCode,
+            ModuleCode = dto.ModuleCode,
+            VersionNo = maxVer + 1,
+            CalcEngineType = dto.CalcEngineType,
+            DataSourceCode = dto.DataSourceCode,
+            SqlScript = dto.SqlScript,
+            SqlParametersJson = dto.SqlParametersJson,
+            TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds),
+            PublishStatus = StatusDraft,
+            IsCurrent = false,
+            Remark = dto.Remark,
+            CreatedBy = operatorName,
+            CreatedAt = DateTime.Now,
+        };
+        entity.Id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
+        return ToDto(entity);
+    }
+
+    /// <summary>编辑草稿(仅 DRAFT 可改;已发布/停用版本不可改,须新建版本)。</summary>
+    public async Task UpdateDraftAsync(long id, KpiCalcConfigUpsertDto dto, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        if (entity.PublishStatus != StatusDraft)
+            throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
+        ValidateEngineType(dto.CalcEngineType);
+        entity.CalcEngineType = dto.CalcEngineType;
+        entity.DataSourceCode = dto.DataSourceCode;
+        entity.SqlScript = dto.SqlScript;
+        entity.SqlParametersJson = dto.SqlParametersJson;
+        entity.TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds);
+        entity.Remark = dto.Remark;
+        entity.UpdatedBy = operatorName;
+        entity.UpdatedAt = DateTime.Now;
+        await _db.Updateable(entity).ExecuteCommandAsync();
+    }
+
+    /// <summary>删除草稿(仅 DRAFT)。</summary>
+    public async Task DeleteAsync(long id)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        if (entity.PublishStatus != StatusDraft)
+            throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
+        await _db.Deleteable<AdoSmartOpsKpiCalcConfig>().Where(x => x.Id == id).ExecuteCommandAsync();
+    }
+
+    /// <summary>SQL 安全校验(多层非正则)。</summary>
+    public KpiSqlValidateResultDto Validate(string? sql)
+    {
+        var r = KpiSqlSecurityValidator.Validate(sql);
+        return new KpiSqlValidateResultDto
+        {
+            Ok = r.Ok,
+            ValidationStatus = r.Ok ? "VALID" : "INVALID",
+            ErrorCode = r.ErrorCode,
+            ErrorMessage = r.ErrorMessage,
+            ReferencedTables = r.ReferencedTables,
+        };
+    }
+
+    /// <summary>试算(服务端重校验 + 只读执行;不写正式 KPI 值)。</summary>
+    public async Task<KpiSqlPreviewResultDto> PreviewAsync(KpiSqlPreviewDto dto, CancellationToken ct)
+    {
+        var bizDate = (dto.BizDate ?? DateTime.Today.AddDays(-1)).Date;
+        var res = new KpiSqlPreviewResultDto
+        {
+            MetricCode = dto.MetricCode,
+            EngineType = EngineConfigSql,
+            DataSourceCode = dto.DataSourceCode,
+            BizDate = bizDate.ToString("yyyy-MM-dd"),
+        };
+
+        var val = KpiSqlSecurityValidator.Validate(dto.SqlScript);
+        res.ValidationStatus = val.Ok ? "VALID" : "INVALID";
+        if (!val.Ok)
+        {
+            res.Ok = false;
+            res.ResultStatus = "FAILED";
+            res.ErrorCode = val.ErrorCode;
+            res.ErrorMessage = val.ErrorMessage;
+            return res;
+        }
+
+        var tenantId = ResolveKpiTenantId(dto.ModuleCode);
+        var pars = new KpiSqlRunParams
+        {
+            TenantId = tenantId,
+            FactoryId = 1,
+            ModuleCode = dto.ModuleCode,
+            MetricCode = dto.MetricCode,
+            BizDate = bizDate,
+            PeriodStart = bizDate,
+            PeriodEnd = bizDate.AddDays(1).AddSeconds(-1),
+            SourceZtid = "pbxfxp",
+        };
+        var exec = await _executor.ExecuteAsync(dto.DataSourceCode, dto.SqlScript!, dto.TimeoutSeconds, true, pars, ct);
+
+        res.Ok = exec.Status != "FAILED";
+        res.ResultStatus = exec.Status;
+        res.DurationMs = exec.DurationMs;
+        res.RowCount = exec.RowCount;
+        res.MetricValue = exec.MetricValue;
+        res.NumeratorValue = exec.NumeratorValue;
+        res.DenominatorValue = exec.DenominatorValue;
+        res.ResultMessage = exec.ResultMessage;
+        res.ErrorCode = exec.ErrorCode;
+        res.ErrorMessage = exec.ErrorMessage;
+        res.ExecutedParameters =
+            $"tenant_id={pars.TenantId}, factory_id={pars.FactoryId}, module_code={pars.ModuleCode}, " +
+            $"metric_code={pars.MetricCode}, biz_date={pars.BizDate:yyyy-MM-dd}, ztid={pars.SourceZtid}";
+        return res;
+    }
+
+    /// <summary>发布:事务内校验→旧 current 置 0→本版 PUBLISHED+IsCurrent=1(保证单生效版本)。</summary>
+    public async Task PublishAsync(long id, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        if (entity.PublishStatus == StatusRetired)
+            throw Oops.Bah("已停用版本不可发布,请新建版本");
+
+        // CONFIG_SQL 发布前必须再过一次安全校验
+        if (entity.CalcEngineType == EngineConfigSql)
+        {
+            var val = KpiSqlSecurityValidator.Validate(entity.SqlScript);
+            if (!val.Ok) throw Oops.Bah($"SQL 安全校验未通过:{val.ErrorCode} {val.ErrorMessage}");
+            if (string.IsNullOrWhiteSpace(entity.DataSourceCode))
+                throw Oops.Bah("CONFIG_SQL 必须指定数据源");
+        }
+
+        var tenantId = entity.TenantId;
+        var tran = await _db.AsTenant().UseTranAsync(async () =>
+        {
+            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+                .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
+                .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
+                .ExecuteCommandAsync();
+            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+                .SetColumns(x => new AdoSmartOpsKpiCalcConfig
+                {
+                    PublishStatus = StatusPublished, IsCurrent = true,
+                    PublishedBy = operatorName, PublishedAt = DateTime.Now,
+                })
+                .Where(x => x.Id == id)
+                .ExecuteCommandAsync();
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException;
+    }
+
+    /// <summary>激活/回滚:把某历史版本设为当前生效(其它同 KPI 版本 IsCurrent 置 0),事务内。仅对已发布版本。</summary>
+    public async Task ActivateAsync(long id, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        if (entity.PublishStatus != StatusPublished)
+            throw Oops.Bah("只能激活已发布(PUBLISHED)版本;回滚是激活历史已发布版本,不是复制重发");
+
+        var tenantId = entity.TenantId;
+        var tran = await _db.AsTenant().UseTranAsync(async () =>
+        {
+            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+                .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
+                .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
+                .ExecuteCommandAsync();
+            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+                .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = true, UpdatedBy = operatorName, UpdatedAt = DateTime.Now })
+                .Where(x => x.Id == id)
+                .ExecuteCommandAsync();
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException;
+    }
+
+    /// <summary>停用(RETIRED,IsCurrent=0)。停用当前生效版本后无 current → 运行时回落 legacy。</summary>
+    public async Task RetireAsync(long id, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("配置版本不存在");
+        await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
+            .SetColumns(x => new AdoSmartOpsKpiCalcConfig
+            {
+                PublishStatus = StatusRetired, IsCurrent = false,
+                RetiredBy = operatorName, RetiredAt = DateTime.Now,
+            })
+            .Where(x => x.Id == id)
+            .ExecuteCommandAsync();
+    }
+
+    /// <summary>已登记数据源列表(第一版仅本地中台库)。</summary>
+    public List<object> ListDataSources() => new()
+    {
+        new { code = KpiSqlReadOnlyExecutor.LocalDataSourceCode, name = "本地中台库(aidopdev / mdp_std_* / dwd_*)", readOnly = true },
+    };
+
+    private static void ValidateEngineType(string engine)
+    {
+        if (engine != EngineLegacyCode && engine != EngineConfigSql && engine != EngineLegacyTvf)
+            throw Oops.Bah($"非法引擎类型:{engine}(LEGACY_CODE/CONFIG_SQL/LEGACY_TVF)");
+    }
+
+    private static int NormalizeTimeout(int t) =>
+        t <= 0 ? 30 : Math.Min(t, KpiSqlReadOnlyExecutor.SystemMaxTimeoutSeconds);
+
+    private static KpiCalcConfigDto ToDto(AdoSmartOpsKpiCalcConfig e) => new()
+    {
+        Id = e.Id, TenantId = e.TenantId, MetricCode = e.MetricCode, ModuleCode = e.ModuleCode,
+        VersionNo = e.VersionNo, CalcEngineType = e.CalcEngineType, DataSourceCode = e.DataSourceCode,
+        SqlScript = e.SqlScript, SqlParametersJson = e.SqlParametersJson, TimeoutSeconds = e.TimeoutSeconds,
+        PublishStatus = e.PublishStatus, IsCurrent = e.IsCurrent, Remark = e.Remark,
+        CreatedBy = e.CreatedBy, CreatedAt = e.CreatedAt, PublishedBy = e.PublishedBy, PublishedAt = e.PublishedAt,
+    };
+}

+ 144 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiCalcDispatcher.cs

@@ -0,0 +1,144 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>分发结果:引擎、是否应写 KPI 值、指标值、分母状态。</summary>
+public sealed class KpiCalcDispatchResult
+{
+    public string EngineType { get; set; } = "LEGACY_CODE";
+    /// <summary>是否写 KPI 日值。FAILED 时 false → 不写、保留上一成功值。</summary>
+    public bool ShouldUpsert { get; set; } = true;
+    public decimal? MetricValue { get; set; }
+    /// <summary>分母/结果状态:OK / NO_DATA / NO_NUMERATOR / FAILED。</summary>
+    public string DenominatorStatus { get; set; } = "OK";
+}
+
+/// <summary>
+/// KPI 计算运行时分发器:解析当前生效配置 → LEGACY_CODE(用旧代码值) / CONFIG_SQL(只读执行 SQL) / LEGACY_TVF(用旧代码值)。
+/// 无生效配置 → 回落 LEGACY_CODE(向后兼容)。CONFIG_SQL 失败**不 fallback**、不写值、返回 FAILED(保留上一成功值)。
+/// 每次执行落 ado_smart_ops_kpi_calc_run_log(含引擎/版本/SQL 指纹/分子分母/耗时/状态)。
+/// </summary>
+public sealed class KpiCalcDispatcher : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly AdoSmartOpsKpiCalcConfigService _configService;
+    private readonly KpiSqlReadOnlyExecutor _executor;
+
+    public KpiCalcDispatcher(ISqlSugarClient db, AdoSmartOpsKpiCalcConfigService configService, KpiSqlReadOnlyExecutor executor)
+    {
+        _db = db;
+        _configService = configService;
+        _executor = executor;
+    }
+
+    public async Task<KpiCalcDispatchResult> DispatchAsync(
+        string metricCode, string moduleCode, long tenantId, long factoryId,
+        DateTime bizDate, DateTime periodStart, DateTime periodEnd, string sourceZtid,
+        string batchId, string triggerType,
+        decimal? legacyValue, string legacyDenomStatus, CancellationToken ct)
+    {
+        var startedAt = DateTime.Now;
+        var config = await _configService.GetActiveAsync(tenantId, metricCode);
+        var engine = config?.CalcEngineType ?? "LEGACY_CODE";
+        var result = new KpiCalcDispatchResult { EngineType = engine };
+
+        // 无配置 / LEGACY_CODE / LEGACY_TVF → 用旧代码算出的值(旧 Build 已计算并传入)
+        if (config == null || engine == "LEGACY_CODE" || engine == "LEGACY_TVF")
+        {
+            result.MetricValue = legacyValue;
+            result.DenominatorStatus = legacyDenomStatus;
+            result.ShouldUpsert = true;
+            await WriteRunLogAsync(config, metricCode, moduleCode, tenantId, batchId, triggerType, engine, bizDate,
+                startedAt, legacyValue == null ? "NO_DATA" : "SUCCESS", legacyValue, null, null, 0, null, null, null);
+            return result;
+        }
+
+        // CONFIG_SQL:只读执行已发布 SQL
+        var pars = new KpiSqlRunParams
+        {
+            TenantId = tenantId, FactoryId = factoryId, ModuleCode = moduleCode, MetricCode = metricCode,
+            BizDate = bizDate, PeriodStart = periodStart, PeriodEnd = periodEnd, SourceZtid = sourceZtid,
+        };
+        var exec = await _executor.ExecuteAsync(config.DataSourceCode, config.SqlScript ?? "", config.TimeoutSeconds, false, pars, ct);
+
+        switch (exec.Status)
+        {
+            case "SUCCESS":
+                result.MetricValue = exec.MetricValue;
+                result.DenominatorStatus = "OK";
+                result.ShouldUpsert = true;
+                break;
+            case "NO_DATA":
+                result.MetricValue = null;
+                result.DenominatorStatus = "NO_DATA";
+                result.ShouldUpsert = true; // 遵循现有契约:NO_DATA 写 null,不伪造 0
+                break;
+            default: // FAILED
+                result.MetricValue = null;
+                result.DenominatorStatus = "FAILED";
+                result.ShouldUpsert = false; // 不 fallback、不写错误新值、保留上一成功值
+                break;
+        }
+
+        await WriteRunLogAsync(config, metricCode, moduleCode, tenantId, batchId, triggerType, engine, bizDate,
+            startedAt, exec.Status, exec.MetricValue, exec.NumeratorValue, exec.DenominatorValue,
+            exec.RowCount, exec.ErrorCode, exec.ErrorMessage, exec.SqlHash);
+        return result;
+    }
+
+    private async Task WriteRunLogAsync(
+        AdoSmartOpsKpiCalcConfig? config, string metricCode, string moduleCode, long tenantId,
+        string batchId, string triggerType, string engine, DateTime bizDate, DateTime startedAt,
+        string status, decimal? metricValue, decimal? numerator, decimal? denominator, int rowCount,
+        string? errorCode, string? errorMessage, string? sqlHash)
+    {
+        try
+        {
+            var finishedAt = DateTime.Now;
+            var log = new AdoSmartOpsKpiCalcRunLog
+            {
+                TenantId = tenantId,
+                BatchId = batchId,
+                ModuleCode = moduleCode,
+                MetricCode = metricCode,
+                ConfigId = config?.Id,
+                VersionNo = config?.VersionNo,
+                EngineType = engine,
+                DataSourceCode = config?.DataSourceCode,
+                BizDate = bizDate.Date,
+                StartedAt = startedAt,
+                FinishedAt = finishedAt,
+                DurationMs = (long)(finishedAt - startedAt).TotalMilliseconds,
+                Status = status,
+                RowCount = rowCount,
+                MetricValue = metricValue,
+                NumeratorValue = numerator,
+                DenominatorValue = denominator,
+                ErrorCode = errorCode,
+                ErrorMessage = errorMessage,
+                SqlHash = sqlHash,
+                ParameterSnapshot = $"{{\"tenant_id\":{tenantId},\"metric_code\":\"{metricCode}\",\"biz_date\":\"{bizDate:yyyy-MM-dd}\"}}",
+                TriggerType = triggerType,
+                CreateTime = finishedAt,
+            };
+            // 同批次同指标唯一:存在则更新、否则插入(避免撞唯一键 uk_kpi_calc_run_metric_batch)
+            var existing = await _db.Queryable<AdoSmartOpsKpiCalcRunLog>().ClearFilter<ITenantIdFilter>()
+                .Where(x => x.MetricCode == metricCode && x.BatchId == batchId).FirstAsync();
+            if (existing != null)
+            {
+                log.Id = existing.Id;
+                await _db.Updateable(log).ExecuteCommandAsync();
+            }
+            else
+            {
+                await _db.Insertable(log).ExecuteCommandAsync();
+            }
+        }
+        catch
+        {
+            // 运行日志失败不影响主计算链路
+        }
+    }
+}

+ 241 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiSqlReadOnlyExecutor.cs

@@ -0,0 +1,241 @@
+using System.Data;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>单条 KPI SQL 的运行参数(引擎服务端绑定,不信任前端)。</summary>
+public sealed class KpiSqlRunParams
+{
+    public long TenantId { get; set; }
+    public long FactoryId { get; set; } = 1;
+    public string ModuleCode { get; set; } = string.Empty;
+    public string MetricCode { get; set; } = string.Empty;
+    public DateTime BizDate { get; set; }
+    public DateTime PeriodStart { get; set; }
+    public DateTime PeriodEnd { get; set; }
+    public string SourceZtid { get; set; } = "pbxfxp";
+}
+
+/// <summary>KPI SQL 执行结果契约。</summary>
+public sealed class KpiSqlExecutionResult
+{
+    /// <summary>SUCCESS / NO_DATA / FAILED。</summary>
+    public string Status { get; set; } = "FAILED";
+    public decimal? MetricValue { get; set; }
+    public decimal? NumeratorValue { get; set; }
+    public decimal? DenominatorValue { get; set; }
+    public string? ResultStatus { get; set; }
+    public string? ResultMessage { get; set; }
+    public int RowCount { get; set; }
+    public long DurationMs { get; set; }
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+    public string SqlHash { get; set; } = string.Empty;
+}
+
+/// <summary>
+/// KPI 配置 SQL 只读执行器。用**专用隔离连接** + `START TRANSACTION READ ONLY`(DB 层 1792 强制拒写)
+/// + 命令超时 + 参数化 + 单行结果契约解析。绝不在此写任何业务数据;KPI 日值由引擎另行 Upsert。
+/// </summary>
+public sealed class KpiSqlReadOnlyExecutor : ITransient
+{
+    /// <summary>正式执行的系统超时上限(秒)。</summary>
+    public const int SystemMaxTimeoutSeconds = 60;
+
+    /// <summary>试算超时上限(秒,建议小于正式)。</summary>
+    public const int PreviewMaxTimeoutSeconds = 20;
+
+    /// <summary>最大返回行数(契约要求 0/1 行;超过即 FAILED,避免大结果占用连接)。</summary>
+    public const int MaxResultRows = 100;
+
+    /// <summary>本地中台库数据源编码(解析为主库 aidopdev)。</summary>
+    public const string LocalDataSourceCode = "MDP_LOCAL";
+
+    private readonly ISqlSugarClient _db;
+
+    public KpiSqlReadOnlyExecutor(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public static string ComputeSqlHash(string sql)
+    {
+        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(sql ?? string.Empty));
+        return Convert.ToHexString(bytes);
+    }
+
+    /// <summary>
+    /// 只读执行一段 SQL 并解析单行结果契约。dataSourceCode 目前仅支持 MDP_LOCAL(本地中台库)。
+    /// </summary>
+    public async Task<KpiSqlExecutionResult> ExecuteAsync(
+        string? dataSourceCode, string sql, int timeoutSeconds, bool isPreview,
+        KpiSqlRunParams pars, CancellationToken ct)
+    {
+        var startedAt = DateTime.Now;
+        var res = new KpiSqlExecutionResult { SqlHash = ComputeSqlHash(sql) };
+
+        var cap = isPreview ? PreviewMaxTimeoutSeconds : SystemMaxTimeoutSeconds;
+        var timeout = timeoutSeconds <= 0 ? cap : Math.Min(timeoutSeconds, cap);
+
+        var code = string.IsNullOrWhiteSpace(dataSourceCode) ? LocalDataSourceCode : dataSourceCode.Trim();
+        if (!string.Equals(code, LocalDataSourceCode, StringComparison.OrdinalIgnoreCase))
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "DATASOURCE_NOT_SUPPORTED";
+            res.ErrorMessage = $"数据源 {code} 第一版暂不支持(仅 {LocalDataSourceCode})";
+            res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
+            return res;
+        }
+
+        // 专用隔离连接(不复用共享 _db,避免只读事务污染主连接 + 并发干扰)。
+        var main = _db.CurrentConnectionConfig;
+        var roConfig = new ConnectionConfig
+        {
+            ConnectionString = main.ConnectionString,
+            DbType = main.DbType,
+            IsAutoCloseConnection = false,
+        };
+
+        using var roDb = new SqlSugarClient(roConfig);
+        try
+        {
+            roDb.Ado.CommandTimeOut = timeout;
+            await roDb.Ado.ExecuteCommandAsync("START TRANSACTION READ ONLY");
+            try
+            {
+                ct.ThrowIfCancellationRequested();
+                var sugarPars = BuildParameters(pars);
+                var dt = await roDb.Ado.GetDataTableAsync(sql, sugarPars);
+                ParseContract(dt, res);
+            }
+            finally
+            {
+                try { await roDb.Ado.ExecuteCommandAsync("ROLLBACK"); } catch { /* 专用连接随 Dispose 关闭 */ }
+            }
+        }
+        catch (OperationCanceledException) when (ct.IsCancellationRequested)
+        {
+            throw;
+        }
+        catch (Exception ex)
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = ex.Message.IndexOf("timeout", StringComparison.OrdinalIgnoreCase) >= 0
+                            || ex.Message.IndexOf("Command Timeout", StringComparison.OrdinalIgnoreCase) >= 0
+                ? "TIMEOUT" : "SQL_ERROR";
+            res.ErrorMessage = Truncate(ex.Message, 480);
+        }
+
+        res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
+        return res;
+    }
+
+    private static SugarParameter[] BuildParameters(KpiSqlRunParams p) => new[]
+    {
+        new SugarParameter("@tenant_id", p.TenantId),
+        new SugarParameter("@factory_id", p.FactoryId),
+        new SugarParameter("@module_code", p.ModuleCode),
+        new SugarParameter("@metric_code", p.MetricCode),
+        new SugarParameter("@biz_date", p.BizDate.Date),
+        new SugarParameter("@period_start", p.PeriodStart),
+        new SugarParameter("@period_end", p.PeriodEnd),
+        new SugarParameter("@ztid", p.SourceZtid),
+    };
+
+    private static void ParseContract(DataTable dt, KpiSqlExecutionResult res)
+    {
+        res.RowCount = dt.Rows.Count;
+        if (dt.Rows.Count == 0)
+        {
+            res.Status = "NO_DATA";
+            return;
+        }
+        if (dt.Rows.Count > MaxResultRows || dt.Rows.Count > 1)
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "INVALID_RESULT_ROWS";
+            res.ErrorMessage = $"正式执行 SQL 只能返回 0 或 1 行,实际 {dt.Rows.Count} 行";
+            return;
+        }
+
+        var row = dt.Rows[0];
+        if (!HasColumn(dt, "metric_value"))
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "MISSING_METRIC_VALUE";
+            res.ErrorMessage = "结果必须含列 metric_value";
+            return;
+        }
+
+        res.NumeratorValue = TryDecimal(row, dt, "numerator_value");
+        res.DenominatorValue = TryDecimal(row, dt, "denominator_value");
+        res.ResultStatus = TryString(row, dt, "result_status");
+        res.ResultMessage = TryString(row, dt, "result_message");
+
+        var mvObj = row[GetColumn(dt, "metric_value")!];
+        if (mvObj == null || mvObj == DBNull.Value)
+        {
+            res.Status = "NO_DATA";
+            res.MetricValue = null;
+            return;
+        }
+        if (!TryToDecimal(mvObj, out var mv))
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "NON_NUMERIC";
+            res.ErrorMessage = $"metric_value 非数值:{Truncate(mvObj.ToString() ?? "", 60)}";
+            return;
+        }
+        res.MetricValue = mv;
+        res.Status = "SUCCESS";
+    }
+
+    private static bool HasColumn(DataTable dt, string name) => GetColumn(dt, name) != null;
+
+    private static string? GetColumn(DataTable dt, string name)
+    {
+        foreach (DataColumn c in dt.Columns)
+            if (string.Equals(c.ColumnName, name, StringComparison.OrdinalIgnoreCase)) return c.ColumnName;
+        return null;
+    }
+
+    private static decimal? TryDecimal(DataRow row, DataTable dt, string name)
+    {
+        var col = GetColumn(dt, name);
+        if (col == null) return null;
+        var v = row[col];
+        if (v == null || v == DBNull.Value) return null;
+        return TryToDecimal(v, out var d) ? d : (decimal?)null;
+    }
+
+    private static string? TryString(DataRow row, DataTable dt, string name)
+    {
+        var col = GetColumn(dt, name);
+        if (col == null) return null;
+        var v = row[col];
+        return v == null || v == DBNull.Value ? null : v.ToString();
+    }
+
+    private static bool TryToDecimal(object v, out decimal d)
+    {
+        switch (v)
+        {
+            case decimal dec: d = dec; return true;
+            case double db: d = (decimal)db; return true;
+            case float f: d = (decimal)f; return true;
+            case long l: d = l; return true;
+            case int i: d = i; return true;
+            case short s: d = s; return true;
+            case byte b: d = b; return true;
+            default:
+                return decimal.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture),
+                    NumberStyles.Any, CultureInfo.InvariantCulture, out d);
+        }
+    }
+
+    private static string Truncate(string s, int max) =>
+        string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
+}

+ 139 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiSqlSecurityValidator.cs

@@ -0,0 +1,139 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// KPI 配置 SQL 安全校验(多层、非仅正则)。真正的写保护由只读事务在 DB 层强制(KpiSqlReadOnlyExecutor);
+/// 本校验器是意图闸门 + 纵深防御:单语句、起始 token 白名单、token 级 denylist、FROM 表白名单。
+/// 顺序:去注释 → 单语句门 → 起始 token 白名单(SELECT/WITH) → denylist(token 边界) → FROM 表白名单。
+/// </summary>
+public static class KpiSqlSecurityValidator
+{
+    /// <summary>允许查询的表前缀(中台内部表;租户谓词软限降级——限制 FROM 目标而非静态分析谓词)。</summary>
+    public static readonly string[] AllowedTablePrefixes =
+    {
+        "mdp_std_", "mdp_stg_", "dwd_", "ado_s9_kpi_value_", "ado_smart_ops_kpi_atomic"
+    };
+
+    // token 边界匹配的危险关键字(去注释后,按单词边界;避免子串误伤列名如 create_time)。
+    private static readonly string[] DenyKeywords =
+    {
+        "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE", "DROP", "ALTER", "CREATE", "TRUNCATE",
+        "GRANT", "REVOKE", "RENAME", "LOAD", "CALL", "EXEC", "EXECUTE", "SET", "LOCK", "UNLOCK",
+        "HANDLER", "PREPARE", "DEALLOCATE", "INTO", "OUTFILE", "DUMPFILE", "USE", "SHOW", "DESCRIBE",
+        "ATTACH", "PRAGMA", "COPY"
+    };
+
+    private static readonly HashSet<string> StartTokenWhitelist = new(StringComparer.OrdinalIgnoreCase) { "SELECT", "WITH" };
+
+    public sealed class Result
+    {
+        public bool Ok { get; set; }
+        public string? ErrorCode { get; set; }
+        public string? ErrorMessage { get; set; }
+        public List<string> ReferencedTables { get; } = new();
+
+        public static Result Fail(string code, string msg) => new() { Ok = false, ErrorCode = code, ErrorMessage = msg };
+    }
+
+    public static Result Validate(string? rawSql)
+    {
+        if (string.IsNullOrWhiteSpace(rawSql))
+            return Result.Fail("EMPTY_SQL", "SQL 不能为空");
+
+        // 1) 去注释:-- 行注释、# 行注释、/* */ 块注释(防止注释绕过关键字/多语句检测)
+        var sql = StripComments(rawSql).Trim();
+        if (sql.Length == 0)
+            return Result.Fail("EMPTY_SQL", "SQL 去注释后为空");
+
+        // 2) 单语句门:去尾部分号后,若仍含分号 → 多语句,拒
+        var trimmed = sql.TrimEnd().TrimEnd(';').TrimEnd();
+        if (trimmed.Contains(';'))
+            return Result.Fail("MULTI_STATEMENT", "只允许单条语句,禁止分号多语句");
+        if (trimmed.Length == 0)
+            return Result.Fail("EMPTY_SQL", "SQL 为空语句");
+
+        // token 化(按非标识符字符切分,保留 . 以便识别 schema.table)
+        var tokens = Tokenize(trimmed);
+        if (tokens.Count == 0)
+            return Result.Fail("EMPTY_SQL", "SQL 无有效 token");
+
+        // 3) 起始 token 白名单:必须以 SELECT 或 WITH 开头
+        if (!StartTokenWhitelist.Contains(tokens[0]))
+            return Result.Fail("NOT_SELECT", "只允许 SELECT 或 WITH ... SELECT 查询");
+        // WITH 需其后出现 SELECT 主语句
+        if (string.Equals(tokens[0], "WITH", StringComparison.OrdinalIgnoreCase)
+            && !tokens.Any(t => string.Equals(t, "SELECT", StringComparison.OrdinalIgnoreCase)))
+            return Result.Fail("NOT_SELECT", "WITH 之后必须为 SELECT 查询");
+
+        // 4) token 级 denylist(单词边界匹配,非裸子串)
+        var upperTokens = new HashSet<string>(tokens.Select(t => t.ToUpperInvariant()));
+        foreach (var kw in DenyKeywords)
+        {
+            if (upperTokens.Contains(kw))
+                return Result.Fail("FORBIDDEN_KEYWORD", $"禁止的关键字/操作:{kw}");
+        }
+
+        // 5) FROM 表白名单:提取 FROM/JOIN 后的表名,须命中允许前缀
+        var result = new Result { Ok = true };
+        var tables = ExtractTables(tokens);
+        result.ReferencedTables.AddRange(tables);
+        foreach (var tbl in tables)
+        {
+            var bare = tbl.Contains('.') ? tbl.Substring(tbl.LastIndexOf('.') + 1) : tbl;
+            bare = bare.Trim('`', '"', '[', ']');
+            if (!AllowedTablePrefixes.Any(p => bare.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
+                return Result.Fail("TABLE_NOT_ALLOWED",
+                    $"表 {tbl} 不在允许的中台数据集范围(仅 {string.Join("/", AllowedTablePrefixes)})");
+        }
+        return result;
+    }
+
+    private static string StripComments(string sql)
+    {
+        // 块注释 /* ... */(含跨行)
+        sql = Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline);
+        var sb = new StringBuilder();
+        foreach (var line in sql.Split('\n'))
+        {
+            var l = line;
+            var dash = l.IndexOf("--", StringComparison.Ordinal);
+            var hash = l.IndexOf('#');
+            var cut = -1;
+            if (dash >= 0) cut = dash;
+            if (hash >= 0 && (cut < 0 || hash < cut)) cut = hash;
+            if (cut >= 0) l = l.Substring(0, cut);
+            sb.Append(l).Append('\n');
+        }
+        return sb.ToString();
+    }
+
+    private static List<string> Tokenize(string sql)
+    {
+        // 保留标识符字符 + . + @(参数);其余作分隔。字符串字面量整体折叠为占位,避免其中关键字误判。
+        var noStrings = Regex.Replace(sql, @"'([^'\\]|\\.)*'", " '' ");
+        noStrings = Regex.Replace(noStrings, "\"([^\"\\\\]|\\\\.)*\"", " \"\" ");
+        var matches = Regex.Matches(noStrings, @"[A-Za-z0-9_@\.\$]+");
+        return matches.Select(m => m.Value).Where(v => v.Length > 0).ToList();
+    }
+
+    private static List<string> ExtractTables(List<string> tokens)
+    {
+        var tables = new List<string>();
+        for (var i = 0; i < tokens.Count - 1; i++)
+        {
+            var t = tokens[i].ToUpperInvariant();
+            if (t == "FROM" || t == "JOIN")
+            {
+                var next = tokens[i + 1];
+                // 跳过子查询开括号场景:Tokenize 已去括号,子查询里 FROM 会各自被扫描到;
+                // 若 next 是保留字(SELECT 等)说明是子查询,跳过表名收集。
+                if (StartTokenWhitelist.Contains(next) || string.Equals(next, "SELECT", StringComparison.OrdinalIgnoreCase))
+                    continue;
+                tables.Add(next);
+            }
+        }
+        return tables;
+    }
+}