KpiDimensionSqlExecutor.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. using System.Data;
  2. using System.Globalization;
  3. using System.Security.Cryptography;
  4. using System.Text;
  5. using SqlSugar;
  6. namespace Admin.NET.Plugin.AiDOP.SmartOps;
  7. /// <summary>解析后的单条维度结果行。</summary>
  8. public sealed class KpiDimensionRow
  9. {
  10. public DateTime ValueDate { get; set; }
  11. public string DimensionType { get; set; } = string.Empty;
  12. public string DimensionCode { get; set; } = string.Empty;
  13. public string? DimensionName { get; set; }
  14. public long? OrgId { get; set; }
  15. public long? FactoryId { get; set; }
  16. public string? MaterialCode { get; set; }
  17. public string? WorkOrderNo { get; set; }
  18. public string? CategoryCode { get; set; }
  19. public string? WarehouseCode { get; set; }
  20. public string? OrderNo { get; set; }
  21. public string? CustomerCode { get; set; }
  22. public string? ProductCode { get; set; }
  23. public string? EquipmentCode { get; set; }
  24. public decimal? MetricValue { get; set; }
  25. public decimal? Numerator { get; set; }
  26. public decimal? Denominator { get; set; }
  27. public decimal? SumValue { get; set; }
  28. public int? SampleCount { get; set; }
  29. public string? SourceKey { get; set; }
  30. }
  31. /// <summary>维度 SQL 执行结果契约(多行)。</summary>
  32. public sealed class KpiDimensionExecutionResult
  33. {
  34. /// <summary>SUCCESS / NO_DATA / FAILED。</summary>
  35. public string Status { get; set; } = "FAILED";
  36. public int RowCount { get; set; }
  37. public long DurationMs { get; set; }
  38. public string? ErrorCode { get; set; }
  39. public string? ErrorMessage { get; set; }
  40. public string SqlHash { get; set; } = string.Empty;
  41. public List<string> OutputColumns { get; set; } = new();
  42. public List<KpiDimensionRow> Rows { get; set; } = new();
  43. }
  44. /// <summary>
  45. /// KPI 维度 SQL 只读执行器。与 <see cref="KpiSqlReadOnlyExecutor"/> 同构的只读隔离连接
  46. /// + START TRANSACTION READ ONLY + 参数化,但解析**多行维度结果契约**(用于筛选/下钻)。
  47. /// 必填列:value_date / dimension_type / dimension_code。绝不写业务数据。
  48. /// </summary>
  49. public sealed class KpiDimensionSqlExecutor : ITransient
  50. {
  51. public const int SystemMaxTimeoutSeconds = 120;
  52. public const int PreviewMaxTimeoutSeconds = 30;
  53. /// <summary>维度结果最大行数(超过即 FAILED,避免大结果占用连接)。</summary>
  54. public const int MaxResultRows = 5000;
  55. /// <summary>本地中台库数据源编码(解析为主库 aidopdev)。</summary>
  56. public const string LocalDataSourceCode = "MDP_LOCAL";
  57. private readonly ISqlSugarClient _db;
  58. public KpiDimensionSqlExecutor(ISqlSugarClient db)
  59. {
  60. _db = db;
  61. }
  62. public static string ComputeSqlHash(string sql)
  63. {
  64. var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(sql ?? string.Empty));
  65. return Convert.ToHexString(bytes);
  66. }
  67. public async Task<KpiDimensionExecutionResult> ExecuteAsync(
  68. string? dataSourceCode, string sql, int timeoutSeconds, bool isPreview,
  69. KpiSqlRunParams pars, CancellationToken ct)
  70. {
  71. var startedAt = DateTime.Now;
  72. var res = new KpiDimensionExecutionResult { SqlHash = ComputeSqlHash(sql) };
  73. var cap = isPreview ? PreviewMaxTimeoutSeconds : SystemMaxTimeoutSeconds;
  74. var timeout = timeoutSeconds <= 0 ? cap : Math.Min(timeoutSeconds, cap);
  75. var code = string.IsNullOrWhiteSpace(dataSourceCode) ? LocalDataSourceCode : dataSourceCode.Trim();
  76. if (!string.Equals(code, LocalDataSourceCode, StringComparison.OrdinalIgnoreCase))
  77. {
  78. res.Status = "FAILED";
  79. res.ErrorCode = "DATASOURCE_NOT_SUPPORTED";
  80. res.ErrorMessage = $"数据源 {code} 暂不支持(仅 {LocalDataSourceCode})";
  81. res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
  82. return res;
  83. }
  84. var main = _db.CurrentConnectionConfig;
  85. var roConfig = new ConnectionConfig
  86. {
  87. ConnectionString = main.ConnectionString,
  88. DbType = main.DbType,
  89. IsAutoCloseConnection = false,
  90. };
  91. using var roDb = new SqlSugarClient(roConfig);
  92. try
  93. {
  94. roDb.Ado.CommandTimeOut = timeout;
  95. await roDb.Ado.ExecuteCommandAsync("START TRANSACTION READ ONLY");
  96. try
  97. {
  98. ct.ThrowIfCancellationRequested();
  99. var sugarPars = BuildParameters(pars);
  100. var dt = await roDb.Ado.GetDataTableAsync(sql, sugarPars);
  101. ParseContract(dt, res);
  102. }
  103. finally
  104. {
  105. try { await roDb.Ado.ExecuteCommandAsync("ROLLBACK"); } catch { /* 专用连接随 Dispose 关闭 */ }
  106. }
  107. }
  108. catch (OperationCanceledException) when (ct.IsCancellationRequested)
  109. {
  110. throw;
  111. }
  112. catch (Exception ex)
  113. {
  114. res.Status = "FAILED";
  115. res.ErrorCode = ex.Message.IndexOf("timeout", StringComparison.OrdinalIgnoreCase) >= 0
  116. || ex.Message.IndexOf("Command Timeout", StringComparison.OrdinalIgnoreCase) >= 0
  117. ? "TIMEOUT" : "SQL_ERROR";
  118. res.ErrorMessage = Truncate(ex.Message, 480);
  119. }
  120. res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
  121. return res;
  122. }
  123. private static SugarParameter[] BuildParameters(KpiSqlRunParams p) => new[]
  124. {
  125. new SugarParameter("@tenant_id", p.TenantId),
  126. new SugarParameter("@factory_id", p.FactoryId),
  127. new SugarParameter("@module_code", p.ModuleCode),
  128. new SugarParameter("@metric_code", p.MetricCode),
  129. new SugarParameter("@biz_date", p.BizDate.Date),
  130. new SugarParameter("@period_start", p.PeriodStart),
  131. new SugarParameter("@period_end", p.PeriodEnd),
  132. new SugarParameter("@ztid", p.SourceZtid),
  133. };
  134. private static readonly string[] RequiredColumns = { "value_date", "dimension_type", "dimension_code" };
  135. private static void ParseContract(DataTable dt, KpiDimensionExecutionResult res)
  136. {
  137. foreach (DataColumn c in dt.Columns) res.OutputColumns.Add(c.ColumnName);
  138. res.RowCount = dt.Rows.Count;
  139. if (dt.Rows.Count == 0)
  140. {
  141. res.Status = "NO_DATA";
  142. return;
  143. }
  144. if (dt.Rows.Count > MaxResultRows)
  145. {
  146. res.Status = "FAILED";
  147. res.ErrorCode = "TOO_MANY_ROWS";
  148. res.ErrorMessage = $"维度结果超过上限 {MaxResultRows} 行(实际 {dt.Rows.Count}),请在 SQL 内聚合或限定范围";
  149. return;
  150. }
  151. foreach (var col in RequiredColumns)
  152. {
  153. if (GetColumn(dt, col) == null)
  154. {
  155. res.Status = "FAILED";
  156. res.ErrorCode = "MISSING_REQUIRED_COLUMN";
  157. res.ErrorMessage = $"维度结果必须含列 {col}";
  158. return;
  159. }
  160. }
  161. foreach (DataRow row in dt.Rows)
  162. {
  163. var dimCode = TryString(row, dt, "dimension_code");
  164. if (string.IsNullOrWhiteSpace(dimCode)) continue; // 维度编码空的行跳过(无法作为下钻键)
  165. res.Rows.Add(new KpiDimensionRow
  166. {
  167. ValueDate = TryDate(row, dt, "value_date") ?? DateTime.MinValue,
  168. DimensionType = TryString(row, dt, "dimension_type") ?? string.Empty,
  169. DimensionCode = dimCode!,
  170. DimensionName = TryString(row, dt, "dimension_name") ?? dimCode,
  171. OrgId = TryLong(row, dt, "org_id"),
  172. FactoryId = TryLong(row, dt, "factory_id"),
  173. MaterialCode = TryString(row, dt, "material_code"),
  174. WorkOrderNo = TryString(row, dt, "work_order_no"),
  175. CategoryCode = TryString(row, dt, "category_code"),
  176. WarehouseCode = TryString(row, dt, "warehouse_code"),
  177. OrderNo = TryString(row, dt, "order_no"),
  178. CustomerCode = TryString(row, dt, "customer_code"),
  179. ProductCode = TryString(row, dt, "product_code"),
  180. EquipmentCode = TryString(row, dt, "equipment_code"),
  181. MetricValue = TryDecimal(row, dt, "metric_value"),
  182. Numerator = TryDecimal(row, dt, "numerator") ?? TryDecimal(row, dt, "numerator_value"),
  183. Denominator = TryDecimal(row, dt, "denominator") ?? TryDecimal(row, dt, "denominator_value"),
  184. SumValue = TryDecimal(row, dt, "sum_value"),
  185. SampleCount = (int?)TryLong(row, dt, "sample_count"),
  186. SourceKey = TryString(row, dt, "source_key"),
  187. });
  188. }
  189. res.RowCount = res.Rows.Count;
  190. res.Status = res.Rows.Count == 0 ? "NO_DATA" : "SUCCESS";
  191. }
  192. private static string? GetColumn(DataTable dt, string name)
  193. {
  194. foreach (DataColumn c in dt.Columns)
  195. if (string.Equals(c.ColumnName, name, StringComparison.OrdinalIgnoreCase)) return c.ColumnName;
  196. return null;
  197. }
  198. private static string? TryString(DataRow row, DataTable dt, string name)
  199. {
  200. var col = GetColumn(dt, name);
  201. if (col == null) return null;
  202. var v = row[col];
  203. return v == null || v == DBNull.Value ? null : v.ToString();
  204. }
  205. private static DateTime? TryDate(DataRow row, DataTable dt, string name)
  206. {
  207. var col = GetColumn(dt, name);
  208. if (col == null) return null;
  209. var v = row[col];
  210. if (v == null || v == DBNull.Value) return null;
  211. if (v is DateTime d) return d;
  212. return DateTime.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture,
  213. DateTimeStyles.None, out var parsed) ? parsed : (DateTime?)null;
  214. }
  215. private static long? TryLong(DataRow row, DataTable dt, string name)
  216. {
  217. var col = GetColumn(dt, name);
  218. if (col == null) return null;
  219. var v = row[col];
  220. if (v == null || v == DBNull.Value) return null;
  221. return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), NumberStyles.Any,
  222. CultureInfo.InvariantCulture, out var l) ? l : (long?)null;
  223. }
  224. private static decimal? TryDecimal(DataRow row, DataTable dt, string name)
  225. {
  226. var col = GetColumn(dt, name);
  227. if (col == null) return null;
  228. var v = row[col];
  229. if (v == null || v == DBNull.Value) return null;
  230. return TryToDecimal(v, out var d) ? d : (decimal?)null;
  231. }
  232. private static bool TryToDecimal(object v, out decimal d)
  233. {
  234. switch (v)
  235. {
  236. case decimal dec: d = dec; return true;
  237. case double db: d = (decimal)db; return true;
  238. case float f: d = (decimal)f; return true;
  239. case long l: d = l; return true;
  240. case int i: d = i; return true;
  241. case short s: d = s; return true;
  242. case byte b: d = b; return true;
  243. default:
  244. return decimal.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture),
  245. NumberStyles.Any, CultureInfo.InvariantCulture, out d);
  246. }
  247. }
  248. private static string Truncate(string s, int max) =>
  249. string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
  250. }