using System.Data; using System.Globalization; using System.Security.Cryptography; using System.Text; using SqlSugar; namespace Admin.NET.Plugin.AiDOP.SmartOps; /// 解析后的单条维度结果行。 public sealed class KpiDimensionRow { public DateTime ValueDate { get; set; } public string DimensionType { get; set; } = string.Empty; public string DimensionCode { get; set; } = string.Empty; public string? DimensionName { get; set; } public long? OrgId { get; set; } public long? FactoryId { get; set; } public string? MaterialCode { get; set; } public string? WorkOrderNo { get; set; } public string? CategoryCode { get; set; } public string? WarehouseCode { get; set; } public string? OrderNo { get; set; } public string? CustomerCode { get; set; } public string? ProductCode { get; set; } public string? EquipmentCode { get; set; } public decimal? MetricValue { get; set; } public decimal? Numerator { get; set; } public decimal? Denominator { get; set; } public decimal? SumValue { get; set; } public int? SampleCount { get; set; } public string? SourceKey { get; set; } } /// 维度 SQL 执行结果契约(多行)。 public sealed class KpiDimensionExecutionResult { /// SUCCESS / NO_DATA / FAILED。 public string Status { get; set; } = "FAILED"; 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; public List OutputColumns { get; set; } = new(); public List Rows { get; set; } = new(); } /// /// KPI 维度 SQL 只读执行器。与 同构的只读隔离连接 /// + START TRANSACTION READ ONLY + 参数化,但解析**多行维度结果契约**(用于筛选/下钻)。 /// 必填列:value_date / dimension_type / dimension_code。绝不写业务数据。 /// public sealed class KpiDimensionSqlExecutor : ITransient { public const int SystemMaxTimeoutSeconds = 120; public const int PreviewMaxTimeoutSeconds = 30; /// 维度结果最大行数(超过即 FAILED,避免大结果占用连接)。 public const int MaxResultRows = 5000; /// 本地中台库数据源编码(解析为主库 aidopdev)。 public const string LocalDataSourceCode = "MDP_LOCAL"; private readonly ISqlSugarClient _db; public KpiDimensionSqlExecutor(ISqlSugarClient db) { _db = db; } public static string ComputeSqlHash(string sql) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(sql ?? string.Empty)); return Convert.ToHexString(bytes); } public async Task ExecuteAsync( string? dataSourceCode, string sql, int timeoutSeconds, bool isPreview, KpiSqlRunParams pars, CancellationToken ct) { var startedAt = DateTime.Now; var res = new KpiDimensionExecutionResult { 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; } 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 readonly string[] RequiredColumns = { "value_date", "dimension_type", "dimension_code" }; private static void ParseContract(DataTable dt, KpiDimensionExecutionResult res) { foreach (DataColumn c in dt.Columns) res.OutputColumns.Add(c.ColumnName); res.RowCount = dt.Rows.Count; if (dt.Rows.Count == 0) { res.Status = "NO_DATA"; return; } if (dt.Rows.Count > MaxResultRows) { res.Status = "FAILED"; res.ErrorCode = "TOO_MANY_ROWS"; res.ErrorMessage = $"维度结果超过上限 {MaxResultRows} 行(实际 {dt.Rows.Count}),请在 SQL 内聚合或限定范围"; return; } foreach (var col in RequiredColumns) { if (GetColumn(dt, col) == null) { res.Status = "FAILED"; res.ErrorCode = "MISSING_REQUIRED_COLUMN"; res.ErrorMessage = $"维度结果必须含列 {col}"; return; } } foreach (DataRow row in dt.Rows) { var dimCode = TryString(row, dt, "dimension_code"); if (string.IsNullOrWhiteSpace(dimCode)) continue; // 维度编码空的行跳过(无法作为下钻键) res.Rows.Add(new KpiDimensionRow { ValueDate = TryDate(row, dt, "value_date") ?? DateTime.MinValue, DimensionType = TryString(row, dt, "dimension_type") ?? string.Empty, DimensionCode = dimCode!, DimensionName = TryString(row, dt, "dimension_name") ?? dimCode, OrgId = TryLong(row, dt, "org_id"), FactoryId = TryLong(row, dt, "factory_id"), MaterialCode = TryString(row, dt, "material_code"), WorkOrderNo = TryString(row, dt, "work_order_no"), CategoryCode = TryString(row, dt, "category_code"), WarehouseCode = TryString(row, dt, "warehouse_code"), OrderNo = TryString(row, dt, "order_no"), CustomerCode = TryString(row, dt, "customer_code"), ProductCode = TryString(row, dt, "product_code"), EquipmentCode = TryString(row, dt, "equipment_code"), MetricValue = TryDecimal(row, dt, "metric_value"), Numerator = TryDecimal(row, dt, "numerator") ?? TryDecimal(row, dt, "numerator_value"), Denominator = TryDecimal(row, dt, "denominator") ?? TryDecimal(row, dt, "denominator_value"), SumValue = TryDecimal(row, dt, "sum_value"), SampleCount = (int?)TryLong(row, dt, "sample_count"), SourceKey = TryString(row, dt, "source_key"), }); } res.RowCount = res.Rows.Count; res.Status = res.Rows.Count == 0 ? "NO_DATA" : "SUCCESS"; } 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 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 DateTime? TryDate(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; if (v is DateTime d) return d; return DateTime.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ? parsed : (DateTime?)null; } private static long? TryLong(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 long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var l) ? l : (long?)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 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)); }