AdoSmartOpsKpiCalcConfigService.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. using Admin.NET.Core;
  2. using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
  3. using Admin.NET.Plugin.AiDOP.Entity;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using SqlSugar;
  6. namespace Admin.NET.Plugin.AiDOP.SmartOps;
  7. /// <summary>
  8. /// KPI 计算配置服务:CRUD + 校验 + 试算 + 发布/停用/激活(发布状态机,单生效版本事务保证)。
  9. /// 租户显式控制(ClearFilter):配置落在"分发器运行时查找的租户"= KPI 数据租户(S5=AidopSourceTenantMap 解析),
  10. /// 而非 API 调用者 JWT 租户;否则运行时找不到配置。SqlScript 属高危配置,接口须登录鉴权(Controller 层)。
  11. /// </summary>
  12. public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
  13. {
  14. private const string EngineLegacyCode = "LEGACY_CODE";
  15. private const string EngineConfigSql = "CONFIG_SQL";
  16. private const string EngineLegacyTvf = "LEGACY_TVF";
  17. private const string StatusDraft = "DRAFT";
  18. private const string StatusPublished = "PUBLISHED";
  19. private const string StatusRetired = "RETIRED";
  20. private static readonly string[] TenantScopedModules = { "S5", "S6", "S7" };
  21. private readonly ISqlSugarClient _db;
  22. private readonly KpiSqlReadOnlyExecutor _executor;
  23. public AdoSmartOpsKpiCalcConfigService(ISqlSugarClient db, KpiSqlReadOnlyExecutor executor)
  24. {
  25. _db = db;
  26. _executor = executor;
  27. }
  28. /// <summary>解析配置应归属的租户(= 运行时 KPI 落库租户)。S5/S6/S7 走 T8 账套映射(pbxfxp→AIDOP)。</summary>
  29. public static long ResolveKpiTenantId(string moduleCode)
  30. {
  31. if (TenantScopedModules.Contains((moduleCode ?? "").Trim().ToUpperInvariant()))
  32. return AidopSourceTenantMap.ResolveTenantId("pbxfxp");
  33. return SqlSugarConst.DefaultTenantId;
  34. }
  35. private ISugarQueryable<AdoSmartOpsKpiCalcConfig> Query() =>
  36. _db.Queryable<AdoSmartOpsKpiCalcConfig>().ClearFilter<ITenantIdFilter>();
  37. /// <summary>某 KPI 的全部版本(按版本号倒序)。</summary>
  38. public async Task<List<KpiCalcConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
  39. {
  40. var tenantId = ResolveKpiTenantId(moduleCode);
  41. var list = await Query()
  42. .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId)
  43. .OrderBy(x => x.VersionNo, OrderByType.Desc)
  44. .ToListAsync();
  45. return list.Select(ToDto).ToList();
  46. }
  47. /// <summary>当前生效配置(PUBLISHED + IsCurrent),无则 null(= 走 legacy)。</summary>
  48. public async Task<AdoSmartOpsKpiCalcConfig?> GetActiveAsync(long tenantId, string metricCode)
  49. {
  50. return await Query()
  51. .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId
  52. && x.IsCurrent && x.PublishStatus == StatusPublished)
  53. .FirstAsync();
  54. }
  55. /// <summary>新增草稿(VersionNo = 现有最大 + 1)。</summary>
  56. public async Task<KpiCalcConfigDto> CreateDraftAsync(KpiCalcConfigUpsertDto dto, string operatorName)
  57. {
  58. ValidateEngineType(dto.CalcEngineType);
  59. var tenantId = ResolveKpiTenantId(dto.ModuleCode);
  60. var maxVer = await Query()
  61. .Where(x => x.MetricCode == dto.MetricCode && x.TenantId == tenantId)
  62. .MaxAsync(x => (int?)x.VersionNo) ?? 0;
  63. var entity = new AdoSmartOpsKpiCalcConfig
  64. {
  65. TenantId = tenantId,
  66. MetricCode = dto.MetricCode,
  67. ModuleCode = dto.ModuleCode,
  68. VersionNo = maxVer + 1,
  69. CalcEngineType = dto.CalcEngineType,
  70. DataSourceCode = dto.DataSourceCode,
  71. SqlScript = dto.SqlScript,
  72. SqlParametersJson = dto.SqlParametersJson,
  73. TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds),
  74. PublishStatus = StatusDraft,
  75. IsCurrent = false,
  76. Remark = dto.Remark,
  77. CreatedBy = operatorName,
  78. CreatedAt = DateTime.Now,
  79. };
  80. entity.Id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
  81. return ToDto(entity);
  82. }
  83. /// <summary>编辑草稿(仅 DRAFT 可改;已发布/停用版本不可改,须新建版本)。</summary>
  84. public async Task UpdateDraftAsync(long id, KpiCalcConfigUpsertDto dto, string operatorName)
  85. {
  86. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  87. ?? throw Oops.Bah("配置版本不存在");
  88. if (entity.PublishStatus != StatusDraft)
  89. throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
  90. ValidateEngineType(dto.CalcEngineType);
  91. entity.CalcEngineType = dto.CalcEngineType;
  92. entity.DataSourceCode = dto.DataSourceCode;
  93. entity.SqlScript = dto.SqlScript;
  94. entity.SqlParametersJson = dto.SqlParametersJson;
  95. entity.TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds);
  96. entity.Remark = dto.Remark;
  97. entity.UpdatedBy = operatorName;
  98. entity.UpdatedAt = DateTime.Now;
  99. await _db.Updateable(entity).ExecuteCommandAsync();
  100. }
  101. /// <summary>删除草稿(仅 DRAFT)。</summary>
  102. public async Task DeleteAsync(long id)
  103. {
  104. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  105. ?? throw Oops.Bah("配置版本不存在");
  106. if (entity.PublishStatus != StatusDraft)
  107. throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
  108. await _db.Deleteable<AdoSmartOpsKpiCalcConfig>().Where(x => x.Id == id).ExecuteCommandAsync();
  109. }
  110. /// <summary>SQL 安全校验(多层非正则)。</summary>
  111. public KpiSqlValidateResultDto Validate(string? sql)
  112. {
  113. var r = KpiSqlSecurityValidator.Validate(sql);
  114. return new KpiSqlValidateResultDto
  115. {
  116. Ok = r.Ok,
  117. ValidationStatus = r.Ok ? "VALID" : "INVALID",
  118. ErrorCode = r.ErrorCode,
  119. ErrorMessage = r.ErrorMessage,
  120. ReferencedTables = r.ReferencedTables,
  121. };
  122. }
  123. /// <summary>试算(服务端重校验 + 只读执行;不写正式 KPI 值)。</summary>
  124. public async Task<KpiSqlPreviewResultDto> PreviewAsync(KpiSqlPreviewDto dto, CancellationToken ct)
  125. {
  126. var bizDate = (dto.BizDate ?? DateTime.Today.AddDays(-1)).Date;
  127. var res = new KpiSqlPreviewResultDto
  128. {
  129. MetricCode = dto.MetricCode,
  130. EngineType = EngineConfigSql,
  131. DataSourceCode = dto.DataSourceCode,
  132. BizDate = bizDate.ToString("yyyy-MM-dd"),
  133. };
  134. var val = KpiSqlSecurityValidator.Validate(dto.SqlScript);
  135. res.ValidationStatus = val.Ok ? "VALID" : "INVALID";
  136. if (!val.Ok)
  137. {
  138. res.Ok = false;
  139. res.ResultStatus = "FAILED";
  140. res.ErrorCode = val.ErrorCode;
  141. res.ErrorMessage = val.ErrorMessage;
  142. return res;
  143. }
  144. var tenantId = ResolveKpiTenantId(dto.ModuleCode);
  145. var pars = new KpiSqlRunParams
  146. {
  147. TenantId = tenantId,
  148. FactoryId = 1,
  149. ModuleCode = dto.ModuleCode,
  150. MetricCode = dto.MetricCode,
  151. BizDate = bizDate,
  152. PeriodStart = bizDate,
  153. PeriodEnd = bizDate.AddDays(1).AddSeconds(-1),
  154. SourceZtid = "pbxfxp",
  155. };
  156. var exec = await _executor.ExecuteAsync(dto.DataSourceCode, dto.SqlScript!, dto.TimeoutSeconds, true, pars, ct);
  157. res.Ok = exec.Status != "FAILED";
  158. res.ResultStatus = exec.Status;
  159. res.DurationMs = exec.DurationMs;
  160. res.RowCount = exec.RowCount;
  161. res.MetricValue = exec.MetricValue;
  162. res.NumeratorValue = exec.NumeratorValue;
  163. res.DenominatorValue = exec.DenominatorValue;
  164. res.ResultMessage = exec.ResultMessage;
  165. res.ErrorCode = exec.ErrorCode;
  166. res.ErrorMessage = exec.ErrorMessage;
  167. res.ExecutedParameters =
  168. $"tenant_id={pars.TenantId}, factory_id={pars.FactoryId}, module_code={pars.ModuleCode}, " +
  169. $"metric_code={pars.MetricCode}, biz_date={pars.BizDate:yyyy-MM-dd}, ztid={pars.SourceZtid}";
  170. return res;
  171. }
  172. /// <summary>发布:事务内校验→旧 current 置 0→本版 PUBLISHED+IsCurrent=1(保证单生效版本)。</summary>
  173. public async Task PublishAsync(long id, string operatorName)
  174. {
  175. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  176. ?? throw Oops.Bah("配置版本不存在");
  177. if (entity.PublishStatus == StatusRetired)
  178. throw Oops.Bah("已停用版本不可发布,请新建版本");
  179. // CONFIG_SQL 发布前必须再过一次安全校验
  180. if (entity.CalcEngineType == EngineConfigSql)
  181. {
  182. var val = KpiSqlSecurityValidator.Validate(entity.SqlScript);
  183. if (!val.Ok) throw Oops.Bah($"SQL 安全校验未通过:{val.ErrorCode} {val.ErrorMessage}");
  184. if (string.IsNullOrWhiteSpace(entity.DataSourceCode))
  185. throw Oops.Bah("CONFIG_SQL 必须指定数据源");
  186. }
  187. var tenantId = entity.TenantId;
  188. var tran = await _db.AsTenant().UseTranAsync(async () =>
  189. {
  190. await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
  191. .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
  192. .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
  193. .ExecuteCommandAsync();
  194. await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
  195. .SetColumns(x => new AdoSmartOpsKpiCalcConfig
  196. {
  197. PublishStatus = StatusPublished, IsCurrent = true,
  198. PublishedBy = operatorName, PublishedAt = DateTime.Now,
  199. })
  200. .Where(x => x.Id == id)
  201. .ExecuteCommandAsync();
  202. });
  203. if (!tran.IsSuccess) throw tran.ErrorException;
  204. }
  205. /// <summary>激活/回滚:把某历史版本设为当前生效(其它同 KPI 版本 IsCurrent 置 0),事务内。仅对已发布版本。</summary>
  206. public async Task ActivateAsync(long id, string operatorName)
  207. {
  208. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  209. ?? throw Oops.Bah("配置版本不存在");
  210. if (entity.PublishStatus != StatusPublished)
  211. throw Oops.Bah("只能激活已发布(PUBLISHED)版本;回滚是激活历史已发布版本,不是复制重发");
  212. var tenantId = entity.TenantId;
  213. var tran = await _db.AsTenant().UseTranAsync(async () =>
  214. {
  215. await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
  216. .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
  217. .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
  218. .ExecuteCommandAsync();
  219. await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
  220. .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = true, UpdatedBy = operatorName, UpdatedAt = DateTime.Now })
  221. .Where(x => x.Id == id)
  222. .ExecuteCommandAsync();
  223. });
  224. if (!tran.IsSuccess) throw tran.ErrorException;
  225. }
  226. /// <summary>停用(RETIRED,IsCurrent=0)。停用当前生效版本后无 current → 运行时回落 legacy。</summary>
  227. public async Task RetireAsync(long id, string operatorName)
  228. {
  229. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  230. ?? throw Oops.Bah("配置版本不存在");
  231. await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
  232. .SetColumns(x => new AdoSmartOpsKpiCalcConfig
  233. {
  234. PublishStatus = StatusRetired, IsCurrent = false,
  235. RetiredBy = operatorName, RetiredAt = DateTime.Now,
  236. })
  237. .Where(x => x.Id == id)
  238. .ExecuteCommandAsync();
  239. }
  240. /// <summary>已登记数据源列表(第一版仅本地中台库)。</summary>
  241. public List<object> ListDataSources() => new()
  242. {
  243. new { code = KpiSqlReadOnlyExecutor.LocalDataSourceCode, name = "本地中台库(aidopdev / mdp_std_* / dwd_*)", readOnly = true },
  244. };
  245. private static void ValidateEngineType(string engine)
  246. {
  247. if (engine != EngineLegacyCode && engine != EngineConfigSql && engine != EngineLegacyTvf)
  248. throw Oops.Bah($"非法引擎类型:{engine}(LEGACY_CODE/CONFIG_SQL/LEGACY_TVF)");
  249. }
  250. private static int NormalizeTimeout(int t) =>
  251. t <= 0 ? 30 : Math.Min(t, KpiSqlReadOnlyExecutor.SystemMaxTimeoutSeconds);
  252. private static KpiCalcConfigDto ToDto(AdoSmartOpsKpiCalcConfig e) => new()
  253. {
  254. Id = e.Id, TenantId = e.TenantId, MetricCode = e.MetricCode, ModuleCode = e.ModuleCode,
  255. VersionNo = e.VersionNo, CalcEngineType = e.CalcEngineType, DataSourceCode = e.DataSourceCode,
  256. SqlScript = e.SqlScript, SqlParametersJson = e.SqlParametersJson, TimeoutSeconds = e.TimeoutSeconds,
  257. PublishStatus = e.PublishStatus, IsCurrent = e.IsCurrent, Remark = e.Remark,
  258. CreatedBy = e.CreatedBy, CreatedAt = e.CreatedAt, PublishedBy = e.PublishedBy, PublishedAt = e.PublishedAt,
  259. };
  260. }