KpiSqlReadOnlyExecutor.cs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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>单条 KPI SQL 的运行参数(引擎服务端绑定,不信任前端)。</summary>
  8. public sealed class KpiSqlRunParams
  9. {
  10. public long TenantId { get; set; }
  11. public long FactoryId { get; set; } = 1;
  12. public string ModuleCode { get; set; } = string.Empty;
  13. public string MetricCode { get; set; } = string.Empty;
  14. public DateTime BizDate { get; set; }
  15. public DateTime PeriodStart { get; set; }
  16. public DateTime PeriodEnd { get; set; }
  17. public string SourceZtid { get; set; } = "pbxfxp";
  18. }
  19. /// <summary>KPI SQL 执行结果契约。</summary>
  20. public sealed class KpiSqlExecutionResult
  21. {
  22. /// <summary>SUCCESS / NO_DATA / FAILED。</summary>
  23. public string Status { get; set; } = "FAILED";
  24. public decimal? MetricValue { get; set; }
  25. public decimal? NumeratorValue { get; set; }
  26. public decimal? DenominatorValue { get; set; }
  27. public string? ResultStatus { get; set; }
  28. public string? ResultMessage { get; set; }
  29. public int RowCount { get; set; }
  30. public long DurationMs { get; set; }
  31. public string? ErrorCode { get; set; }
  32. public string? ErrorMessage { get; set; }
  33. public string SqlHash { get; set; } = string.Empty;
  34. }
  35. /// <summary>
  36. /// KPI 配置 SQL 只读执行器。用**专用隔离连接** + `START TRANSACTION READ ONLY`(DB 层 1792 强制拒写)
  37. /// + 命令超时 + 参数化 + 单行结果契约解析。绝不在此写任何业务数据;KPI 日值由引擎另行 Upsert。
  38. /// </summary>
  39. public sealed class KpiSqlReadOnlyExecutor : ITransient
  40. {
  41. /// <summary>正式执行的系统超时上限(秒)。</summary>
  42. public const int SystemMaxTimeoutSeconds = 60;
  43. /// <summary>试算超时上限(秒,建议小于正式)。</summary>
  44. public const int PreviewMaxTimeoutSeconds = 20;
  45. /// <summary>最大返回行数(契约要求 0/1 行;超过即 FAILED,避免大结果占用连接)。</summary>
  46. public const int MaxResultRows = 100;
  47. /// <summary>本地中台库数据源编码(解析为主库 aidopdev)。</summary>
  48. public const string LocalDataSourceCode = "MDP_LOCAL";
  49. private readonly ISqlSugarClient _db;
  50. public KpiSqlReadOnlyExecutor(ISqlSugarClient db)
  51. {
  52. _db = db;
  53. }
  54. public static string ComputeSqlHash(string sql)
  55. {
  56. var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(sql ?? string.Empty));
  57. return Convert.ToHexString(bytes);
  58. }
  59. /// <summary>
  60. /// 只读执行一段 SQL 并解析单行结果契约。dataSourceCode 目前仅支持 MDP_LOCAL(本地中台库)。
  61. /// </summary>
  62. public async Task<KpiSqlExecutionResult> ExecuteAsync(
  63. string? dataSourceCode, string sql, int timeoutSeconds, bool isPreview,
  64. KpiSqlRunParams pars, CancellationToken ct)
  65. {
  66. var startedAt = DateTime.Now;
  67. var res = new KpiSqlExecutionResult { SqlHash = ComputeSqlHash(sql) };
  68. var cap = isPreview ? PreviewMaxTimeoutSeconds : SystemMaxTimeoutSeconds;
  69. var timeout = timeoutSeconds <= 0 ? cap : Math.Min(timeoutSeconds, cap);
  70. var code = string.IsNullOrWhiteSpace(dataSourceCode) ? LocalDataSourceCode : dataSourceCode.Trim();
  71. if (!string.Equals(code, LocalDataSourceCode, StringComparison.OrdinalIgnoreCase))
  72. {
  73. res.Status = "FAILED";
  74. res.ErrorCode = "DATASOURCE_NOT_SUPPORTED";
  75. res.ErrorMessage = $"数据源 {code} 第一版暂不支持(仅 {LocalDataSourceCode})";
  76. res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
  77. return res;
  78. }
  79. // 专用隔离连接(不复用共享 _db,避免只读事务污染主连接 + 并发干扰)。
  80. var main = _db.CurrentConnectionConfig;
  81. var roConfig = new ConnectionConfig
  82. {
  83. ConnectionString = main.ConnectionString,
  84. DbType = main.DbType,
  85. IsAutoCloseConnection = false,
  86. };
  87. using var roDb = new SqlSugarClient(roConfig);
  88. try
  89. {
  90. roDb.Ado.CommandTimeOut = timeout;
  91. await roDb.Ado.ExecuteCommandAsync("START TRANSACTION READ ONLY");
  92. try
  93. {
  94. ct.ThrowIfCancellationRequested();
  95. var sugarPars = BuildParameters(pars);
  96. var dt = await roDb.Ado.GetDataTableAsync(sql, sugarPars);
  97. ParseContract(dt, res);
  98. }
  99. finally
  100. {
  101. try { await roDb.Ado.ExecuteCommandAsync("ROLLBACK"); } catch { /* 专用连接随 Dispose 关闭 */ }
  102. }
  103. }
  104. catch (OperationCanceledException) when (ct.IsCancellationRequested)
  105. {
  106. throw;
  107. }
  108. catch (Exception ex)
  109. {
  110. res.Status = "FAILED";
  111. res.ErrorCode = ex.Message.IndexOf("timeout", StringComparison.OrdinalIgnoreCase) >= 0
  112. || ex.Message.IndexOf("Command Timeout", StringComparison.OrdinalIgnoreCase) >= 0
  113. ? "TIMEOUT" : "SQL_ERROR";
  114. res.ErrorMessage = Truncate(ex.Message, 480);
  115. }
  116. res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
  117. return res;
  118. }
  119. private static SugarParameter[] BuildParameters(KpiSqlRunParams p) => new[]
  120. {
  121. new SugarParameter("@tenant_id", p.TenantId),
  122. new SugarParameter("@factory_id", p.FactoryId),
  123. new SugarParameter("@module_code", p.ModuleCode),
  124. new SugarParameter("@metric_code", p.MetricCode),
  125. new SugarParameter("@biz_date", p.BizDate.Date),
  126. new SugarParameter("@period_start", p.PeriodStart),
  127. new SugarParameter("@period_end", p.PeriodEnd),
  128. new SugarParameter("@ztid", p.SourceZtid),
  129. };
  130. private static void ParseContract(DataTable dt, KpiSqlExecutionResult res)
  131. {
  132. res.RowCount = dt.Rows.Count;
  133. if (dt.Rows.Count == 0)
  134. {
  135. res.Status = "NO_DATA";
  136. return;
  137. }
  138. if (dt.Rows.Count > MaxResultRows || dt.Rows.Count > 1)
  139. {
  140. res.Status = "FAILED";
  141. res.ErrorCode = "INVALID_RESULT_ROWS";
  142. res.ErrorMessage = $"正式执行 SQL 只能返回 0 或 1 行,实际 {dt.Rows.Count} 行";
  143. return;
  144. }
  145. var row = dt.Rows[0];
  146. if (!HasColumn(dt, "metric_value"))
  147. {
  148. res.Status = "FAILED";
  149. res.ErrorCode = "MISSING_METRIC_VALUE";
  150. res.ErrorMessage = "结果必须含列 metric_value";
  151. return;
  152. }
  153. res.NumeratorValue = TryDecimal(row, dt, "numerator_value");
  154. res.DenominatorValue = TryDecimal(row, dt, "denominator_value");
  155. res.ResultStatus = TryString(row, dt, "result_status");
  156. res.ResultMessage = TryString(row, dt, "result_message");
  157. var mvObj = row[GetColumn(dt, "metric_value")!];
  158. if (mvObj == null || mvObj == DBNull.Value)
  159. {
  160. res.Status = "NO_DATA";
  161. res.MetricValue = null;
  162. return;
  163. }
  164. if (!TryToDecimal(mvObj, out var mv))
  165. {
  166. res.Status = "FAILED";
  167. res.ErrorCode = "NON_NUMERIC";
  168. res.ErrorMessage = $"metric_value 非数值:{Truncate(mvObj.ToString() ?? "", 60)}";
  169. return;
  170. }
  171. res.MetricValue = mv;
  172. res.Status = "SUCCESS";
  173. }
  174. private static bool HasColumn(DataTable dt, string name) => GetColumn(dt, name) != null;
  175. private static string? GetColumn(DataTable dt, string name)
  176. {
  177. foreach (DataColumn c in dt.Columns)
  178. if (string.Equals(c.ColumnName, name, StringComparison.OrdinalIgnoreCase)) return c.ColumnName;
  179. return null;
  180. }
  181. private static decimal? TryDecimal(DataRow row, DataTable dt, string name)
  182. {
  183. var col = GetColumn(dt, name);
  184. if (col == null) return null;
  185. var v = row[col];
  186. if (v == null || v == DBNull.Value) return null;
  187. return TryToDecimal(v, out var d) ? d : (decimal?)null;
  188. }
  189. private static string? TryString(DataRow row, DataTable dt, string name)
  190. {
  191. var col = GetColumn(dt, name);
  192. if (col == null) return null;
  193. var v = row[col];
  194. return v == null || v == DBNull.Value ? null : v.ToString();
  195. }
  196. private static bool TryToDecimal(object v, out decimal d)
  197. {
  198. switch (v)
  199. {
  200. case decimal dec: d = dec; return true;
  201. case double db: d = (decimal)db; return true;
  202. case float f: d = (decimal)f; return true;
  203. case long l: d = l; return true;
  204. case int i: d = i; return true;
  205. case short s: d = s; return true;
  206. case byte b: d = b; return true;
  207. default:
  208. return decimal.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture),
  209. NumberStyles.Any, CultureInfo.InvariantCulture, out d);
  210. }
  211. }
  212. private static string Truncate(string s, int max) =>
  213. string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
  214. }