MdpDbEntityOperationService.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. using System.Text.Json;
  2. using Admin.NET.Core.Service;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  4. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  5. namespace Admin.NET.Plugin.AiDOP.DataPlatform;
  6. [ApiDescriptionSettings(Order = 322, Description = "数据中台数据库同步操作")]
  7. [Route("api/DataPlatform")]
  8. [NonUnify]
  9. public class MdpDbEntityOperationService : IDynamicApiController, ITransient
  10. {
  11. private readonly ISqlSugarClient _db;
  12. private readonly UserManager _user;
  13. private readonly MdpSourceScopeFactory _scopes;
  14. private readonly MdpSourcePullDispatcher _dispatcher;
  15. private readonly MdpPullFieldMappingService _mappings;
  16. private readonly SysCacheService _cache;
  17. public MdpDbEntityOperationService(
  18. ISqlSugarClient db,
  19. UserManager user,
  20. MdpSourceScopeFactory scopes,
  21. MdpSourcePullDispatcher dispatcher,
  22. MdpPullFieldMappingService mappings,
  23. SysCacheService cache)
  24. {
  25. _db = db;
  26. _user = user;
  27. _scopes = scopes;
  28. _dispatcher = dispatcher;
  29. _mappings = mappings;
  30. _cache = cache;
  31. }
  32. [DisplayName("数据库同步是否正在运行")]
  33. [HttpGet("entities/{id:long}/run-state")]
  34. public async Task<object> RunState(long id)
  35. {
  36. await EnsureMenuAsync(MdpDbSyncRules.SyncTaskMenuId);
  37. var entity = await FindEntityAsync(id);
  38. var hold = _cache.BeginCacheLock(LockKey(entity), 50, 1000, throwOnFailure: false);
  39. if (hold == null)
  40. return new { running = true };
  41. hold.Dispose();
  42. return new { running = false };
  43. }
  44. [DisplayName("校验数据库同步实体")]
  45. [HttpPost("entities/{id:long}/validate")]
  46. public async Task<object> Validate(long id) => await ValidateCoreAsync(id, connect: true);
  47. [DisplayName("预览数据库同步")]
  48. [HttpPost("entities/{id:long}/preview")]
  49. public async Task<object> Preview(long id, [FromBody] MdpDbPreviewInput? input)
  50. {
  51. await EnsureMenuAsync(MdpDbSyncRules.SyncTaskMenuId);
  52. var entity = await FindEntityAsync(id);
  53. var source = await FindSourceAsync(entity);
  54. var limit = Math.Clamp(input?.Limit ?? 10, 1, 20);
  55. var beforeCursor = entity.LastCursor;
  56. var scope = await _scopes.GetScopeAsync(source);
  57. scope.Ado.CommandTimeOut = 15;
  58. var isSqlServer = IsSqlServer(source);
  59. var table = MdpDbSyncRules.RequireSourceTable(entity.SourceTableName);
  60. var order = string.IsNullOrWhiteSpace(entity.TieBreakerColumn)
  61. ? ""
  62. : " ORDER BY " + Quote(MdpDbSyncRules.RequireIdentifier(entity.TieBreakerColumn, "tie_breaker_column")!, isSqlServer);
  63. var sql = isSqlServer
  64. ? $"SELECT TOP {limit} * FROM {table}{order}"
  65. : $"SELECT * FROM {table}{order} LIMIT {limit}";
  66. var tableData = await scope.Ado.GetDataTableAsync(sql);
  67. var maps = await _mappings.GetAsync(entity.Id);
  68. var rows = new List<object>();
  69. foreach (System.Data.DataRow row in tableData.Rows)
  70. {
  71. var raw = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  72. foreach (System.Data.DataColumn col in tableData.Columns)
  73. raw[col.ColumnName] = RedactCell(col.ColumnName, row[col] == DBNull.Value ? null : row[col]);
  74. var mapped = MdpDbSyncRules.Apply(raw, maps);
  75. rows.Add(new
  76. {
  77. raw,
  78. mapped = mapped.Values,
  79. bizKey = MdpStagingWriter.BuildBizKey(entity.BizKeyExpr, mapped.Values),
  80. errors = mapped.Errors
  81. });
  82. if (JsonSerializer.Serialize(rows).Length > 65536)
  83. break;
  84. }
  85. await WriteAuditAsync(entity, beforeCursor, beforeCursor, "PREVIEW", input?.Reason ?? "预览");
  86. var after = await _db.Queryable<MdpEntity>().Where(x => x.Id == entity.Id).Select(x => x.LastCursor).FirstAsync();
  87. return new { rows, cursorUnchanged = string.Equals(beforeCursor, after, StringComparison.Ordinal), limit };
  88. }
  89. [DisplayName("手工执行数据库同步")]
  90. [HttpPost("entities/{id:long}/run")]
  91. public async Task<object> Run(long id, [FromBody] MdpDbRunInput input, CancellationToken cancellationToken)
  92. {
  93. if (string.IsNullOrWhiteSpace(input.Reason))
  94. throw Oops.Oh("请填写执行原因");
  95. var mode = (input.Mode ?? "INCR").Trim().ToUpperInvariant();
  96. if (mode is not ("FULL" or "INCR" or "ROLLING"))
  97. throw Oops.Oh("执行模式只能是 FULL、INCR 或 ROLLING");
  98. return await RunEntityAsync(id, mode, input.Reason.Trim(), "MANUAL", cancellationToken, _user.TenantId);
  99. }
  100. public async Task<object> RunEntityAsync(long entityId, string mode, string reason, string trigger, CancellationToken cancellationToken, long tenantId)
  101. {
  102. if (string.Equals(trigger, "MANUAL", StringComparison.OrdinalIgnoreCase))
  103. await EnsureMenuAsync(MdpDbSyncRules.SyncTaskMenuId);
  104. var entity = await FindEntityAsync(entityId, tenantId);
  105. var report = await ValidateCoreAsync(entity.Id, connect: true, tenantId, checkMenu: false);
  106. if (report.Ok == false)
  107. throw Oops.Oh(string.Join(";", report.Errors.Select(x => x.Message)));
  108. var hold = _cache.BeginCacheLock(LockKey(entity), 200, 30 * 60 * 1000, throwOnFailure: false);
  109. if (hold == null)
  110. throw Oops.Oh("实体正在运行");
  111. using (hold)
  112. {
  113. var oldCursor = entity.LastCursor;
  114. var ctx = new MdpPullContext
  115. {
  116. TenantId = entity.TenantId == 0 ? tenantId : entity.TenantId,
  117. FullRefresh = mode == "FULL",
  118. SyncWindowType = mode,
  119. RequireConfiguredDbSync = true,
  120. TriggerType = trigger,
  121. BatchId = $"MDP_{trigger}_{DateTime.Now:yyyyMMddHHmmss}"
  122. };
  123. var result = await _dispatcher.PullAllByEntityIdAsync(entity.Id, ctx, cancellationToken);
  124. await WriteAuditAsync(entity, oldCursor, result.NewCursor, trigger == "MANUAL" ? "RUN" : "SCHEDULE", reason);
  125. var status = result.Status ?? MdpDbPullExecutor.SyncStatusSuccess;
  126. if (result.RowsError > 0
  127. || !string.Equals(status, MdpDbPullExecutor.SyncStatusSuccess, StringComparison.OrdinalIgnoreCase))
  128. {
  129. throw new InvalidOperationException(
  130. $"实体 {entity.EntityCode} 同步未完整成功:status={status},错误行={result.RowsError}。本轮不触发中立层投影和模块重算");
  131. }
  132. return new
  133. {
  134. batchId = ctx.BatchId,
  135. mode,
  136. rowsRead = result.RowsPulled,
  137. rowsWritten = result.RowsWritten,
  138. rowsError = result.RowsError,
  139. oldCursor,
  140. newCursor = result.NewCursor,
  141. status,
  142. message = result.Message
  143. };
  144. }
  145. }
  146. [DisplayName("重置数据库同步水位")]
  147. [HttpPost("entities/{id:long}/reset-cursor")]
  148. public async Task<object> ResetCursor(long id, [FromBody] MdpDbResetCursorInput input)
  149. {
  150. await EnsureMenuAsync(MdpDbSyncRules.SyncTaskMenuId);
  151. if (string.IsNullOrWhiteSpace(input.Reason))
  152. throw Oops.Oh("请填写重置原因");
  153. var entity = await FindEntityAsync(id);
  154. var hold = _cache.BeginCacheLock(LockKey(entity), 200, 30 * 60 * 1000, throwOnFailure: false);
  155. if (hold == null)
  156. throw Oops.Oh("实体正在运行");
  157. using (hold)
  158. {
  159. var current = await _db.Queryable<MdpEntity>().Where(x => x.Id == entity.Id).Select(x => x.LastCursor).FirstAsync();
  160. if (!string.Equals(current ?? "", input.ExpectedOldCursor ?? "", StringComparison.Ordinal))
  161. throw Oops.Oh("水位已变化,请刷新后重试");
  162. var now = DateTime.Now;
  163. await _db.Updateable<MdpEntity>()
  164. .SetColumns(x => new MdpEntity { LastCursor = input.NewCursor, UpdateTime = now })
  165. .Where(x => x.Id == entity.Id)
  166. .ExecuteCommandAsync();
  167. await WriteAuditAsync(entity, current, input.NewCursor, "RESET", input.Reason.Trim());
  168. return new { oldCursor = current, newCursor = input.NewCursor };
  169. }
  170. }
  171. [DisplayName("水位操作审计")]
  172. [HttpGet("entities/{id:long}/cursor-audits")]
  173. public async Task<object> CursorAudits(long id)
  174. {
  175. await EnsureMenuAsync(MdpDbSyncRules.SyncTaskMenuId);
  176. var entity = await FindEntityAsync(id);
  177. var rows = await _db.Queryable<MdpEntityCursorAudit>()
  178. .Where(x => x.EntityId == entity.Id && x.TenantId == entity.TenantId)
  179. .OrderBy(x => x.CreateTime, OrderByType.Desc)
  180. .Take(100)
  181. .ToListAsync();
  182. return new { list = rows };
  183. }
  184. private async Task<MdpDbValidateReport> ValidateCoreAsync(long id, bool connect, long? tenantId = null, bool checkMenu = true)
  185. {
  186. if (checkMenu)
  187. await EnsureMenuAsync(MdpDbSyncRules.SyncTaskMenuId);
  188. var entity = await FindEntityAsync(id, tenantId ?? _user.TenantId);
  189. var source = await FindSourceAsync(entity);
  190. var errors = MdpDbSyncRules.ValidateDbEntity(entity, source.SourceType).ToList();
  191. var warnings = new List<MdpConfigIssue>();
  192. if (source.Status != 1)
  193. errors.Add(new MdpConfigIssue("SOURCE_DISABLED", "sourceId", "数据源未启用"));
  194. if (!string.Equals(source.SourceType, "DB", StringComparison.OrdinalIgnoreCase))
  195. errors.Add(new MdpConfigIssue("SOURCE_TYPE", "sourceId", "只有数据库来源可以按方式甲校验"));
  196. if (connect && errors.All(x => x.Code != "SOURCE_TYPE"))
  197. {
  198. try
  199. {
  200. var scope = await _scopes.GetScopeAsync(source);
  201. scope.Ado.CommandTimeOut = 15;
  202. await scope.Ado.GetScalarAsync("SELECT 1");
  203. await CheckSourceShapeAsync(scope, entity, source, errors, warnings);
  204. }
  205. catch (Exception)
  206. {
  207. errors.Add(new MdpConfigIssue("SOURCE_CONNECT", "sourceId", "数据库连接失败"));
  208. }
  209. }
  210. await CheckTargetAsync(entity, errors);
  211. await CheckTenantGrantAsync(entity, source, errors, tenantId ?? _user.TenantId);
  212. CheckBizKey(entity, errors);
  213. return new MdpDbValidateReport(errors.Count == 0, errors, warnings, DateTimeOffset.Now);
  214. }
  215. private async Task CheckSourceShapeAsync(ISqlSugarClient scope, MdpEntity entity, MdpSource source, List<MdpConfigIssue> errors, List<MdpConfigIssue> warnings)
  216. {
  217. var isSqlServer = IsSqlServer(source);
  218. var simple = SimpleName(entity.SourceTableName ?? "");
  219. var existsSql = isSqlServer
  220. ? "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME=@t"
  221. : "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t";
  222. var exists = await scope.Ado.GetIntAsync(existsSql, new SugarParameter("@t", simple));
  223. if (exists <= 0)
  224. {
  225. errors.Add(new MdpConfigIssue("SOURCE_TABLE_MISSING", "sourceTableName", "源表或视图不存在"));
  226. return;
  227. }
  228. var columns = await scope.Ado.SqlQueryAsync<SourceColumnRow>(isSqlServer
  229. ? "SELECT name AS Name, TYPE_NAME(system_type_id) AS DataType FROM sys.columns WHERE object_id=OBJECT_ID(@t)"
  230. : "SELECT COLUMN_NAME AS Name, DATA_TYPE AS DataType FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t",
  231. new SugarParameter("@t", simple));
  232. void Need(string? column, string field, string code)
  233. {
  234. if (string.IsNullOrWhiteSpace(column)) return;
  235. var hit = columns.FirstOrDefault(x => string.Equals(x.Name, column, StringComparison.OrdinalIgnoreCase));
  236. if (hit == null)
  237. errors.Add(new MdpConfigIssue(code, field, $"{column} 在源表中不存在"));
  238. else if (!IsSortable(hit.DataType))
  239. errors.Add(new MdpConfigIssue(code, field, $"{column} 的类型不能用于排序"));
  240. }
  241. if (!string.Equals(entity.SyncMode, "FULL", StringComparison.OrdinalIgnoreCase))
  242. Need(entity.IncrColumn, "incrColumn", "INCR_COLUMN_MISSING");
  243. Need(entity.TieBreakerColumn, "tieBreakerColumn", "TIE_BREAKER_MISSING");
  244. if (!string.IsNullOrWhiteSpace(entity.TieBreakerColumn) && columns.Any(x => string.Equals(x.Name, entity.TieBreakerColumn, StringComparison.OrdinalIgnoreCase)))
  245. {
  246. var col = Quote(entity.TieBreakerColumn.Trim(), isSqlServer);
  247. var table = Quote(simple, isSqlServer);
  248. var sampleSql = isSqlServer
  249. ? $"SELECT {col} AS V FROM (SELECT TOP 2000 {col} AS {col} FROM {table}) s GROUP BY {col} HAVING COUNT(*)>1"
  250. : $"SELECT {col} AS V FROM (SELECT {col} FROM {table} LIMIT 2000) s GROUP BY {col} HAVING COUNT(*)>1";
  251. var dups = await scope.Ado.SqlQueryAsync<dynamic>(sampleSql);
  252. if (dups.Count > 0)
  253. errors.Add(new MdpConfigIssue("TIE_BREAKER_DUPLICATE", "tieBreakerColumn", "稳定排序列在抽样中不唯一"));
  254. else
  255. warnings.Add(new MdpConfigIssue("TIE_BREAKER_SAMPLED", "tieBreakerColumn", "唯一性只检查了最多 2000 行"));
  256. }
  257. }
  258. private async Task CheckTargetAsync(MdpEntity entity, List<MdpConfigIssue> errors)
  259. {
  260. if (string.IsNullOrWhiteSpace(entity.TargetTableName)) return;
  261. var cols = await _db.Ado.SqlQueryAsync<string>(
  262. """
  263. SELECT COLUMN_NAME FROM information_schema.COLUMNS
  264. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  265. """,
  266. new SugarParameter("@t", entity.TargetTableName.Trim()));
  267. if (cols.Count == 0)
  268. {
  269. errors.Add(new MdpConfigIssue("TARGET_TABLE_MISSING", "targetTableName", "目标贴源表不存在"));
  270. return;
  271. }
  272. foreach (var required in MdpDbSyncRules.StagingColumns)
  273. {
  274. if (!cols.Any(x => string.Equals(x, required, StringComparison.OrdinalIgnoreCase)))
  275. errors.Add(new MdpConfigIssue("TARGET_COLUMN_MISSING", "targetTableName", $"目标贴源表缺少 {required}"));
  276. }
  277. }
  278. private async Task CheckTenantGrantAsync(MdpEntity entity, MdpSource source, List<MdpConfigIssue> errors, long actingTenantId)
  279. {
  280. var tenantId = entity.TenantId == 0 ? actingTenantId : entity.TenantId;
  281. var system = string.IsNullOrWhiteSpace(source.SystemCode) ? source.SourceCode : source.SystemCode;
  282. var count = await _db.Ado.GetIntAsync(
  283. "SELECT COUNT(*) FROM mdp_tenant_std_source WHERE tenant_id=@t AND source_system=@s",
  284. new SugarParameter("@t", tenantId),
  285. new SugarParameter("@s", system));
  286. if (count <= 0)
  287. errors.Add(new MdpConfigIssue("TENANT_SOURCE_MISSING", "sourceId", "当前租户未登记该来源"));
  288. }
  289. private void CheckBizKey(MdpEntity entity, List<MdpConfigIssue> errors)
  290. {
  291. if (string.IsNullOrWhiteSpace(entity.BizKeyExpr)) return;
  292. foreach (var field in entity.BizKeyExpr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
  293. {
  294. if (!System.Text.RegularExpressions.Regex.IsMatch(field, "^[A-Za-z_][A-Za-z0-9_]*$"))
  295. errors.Add(new MdpConfigIssue("BIZ_KEY_INVALID", "bizKeyExpr", $"业务键字段 {field} 不合法"));
  296. }
  297. }
  298. private async Task<MdpEntity> FindEntityAsync(long id, long? tenantId = null)
  299. {
  300. var tenant = tenantId ?? _user.TenantId;
  301. var entity = await _db.Queryable<MdpEntity>()
  302. .Where(x => x.Id == id && (x.TenantId == tenant || x.TenantId == 0))
  303. .FirstAsync()
  304. ?? throw Oops.Oh("同步实体不存在");
  305. return entity;
  306. }
  307. private async Task<MdpSource> FindSourceAsync(MdpEntity entity)
  308. {
  309. return await _db.Queryable<MdpSource>().Where(x => x.Id == entity.SourceId).FirstAsync()
  310. ?? throw Oops.Oh("数据源不存在");
  311. }
  312. private async Task WriteAuditAsync(MdpEntity entity, string? oldCursor, string? newCursor, string action, string reason)
  313. {
  314. await _db.Insertable(new MdpEntityCursorAudit
  315. {
  316. TenantId = entity.TenantId == 0 ? _user.TenantId : entity.TenantId,
  317. EntityId = entity.Id,
  318. EntityCode = entity.EntityCode,
  319. OldCursor = oldCursor,
  320. NewCursor = newCursor,
  321. ActionType = action,
  322. Reason = reason.Length > 500 ? reason[..500] : reason,
  323. OperatorId = _user.UserId,
  324. OperatorName = _user.RealName,
  325. CreateTime = DateTime.Now
  326. }).ExecuteCommandAsync();
  327. }
  328. private async Task EnsureMenuAsync(long menuId)
  329. {
  330. if (_user.SuperAdmin || _user.SysAdmin)
  331. return;
  332. var roleIds = await _db.Queryable<SysUserRole>().Where(x => x.UserId == _user.UserId).Select(x => x.RoleId).ToListAsync();
  333. var allowed = roleIds.Count > 0 && await _db.Queryable<SysRoleMenu>().Where(x => roleIds.Contains(x.RoleId) && x.MenuId == menuId).AnyAsync();
  334. if (!allowed)
  335. throw Oops.Oh("没有同步任务权限");
  336. }
  337. private static string LockKey(MdpEntity entity) =>
  338. $"lock:mdp:dbsync:{(entity.TenantId == 0 ? 0 : entity.TenantId)}:{entity.Id}";
  339. private static bool IsSqlServer(MdpSource source) =>
  340. string.Equals(source.DbType, "SQLSERVER", StringComparison.OrdinalIgnoreCase)
  341. || string.Equals(source.DbType, "MSSQL", StringComparison.OrdinalIgnoreCase);
  342. private static string SimpleName(string table)
  343. {
  344. var parts = table.Trim().Trim('[', ']').Split('.', StringSplitOptions.RemoveEmptyEntries);
  345. return parts.Length == 0 ? table : parts[^1].Trim('[', ']');
  346. }
  347. private static string Quote(string name, bool isSqlServer) => isSqlServer ? name : $"`{name}`";
  348. private static bool IsSortable(string? dataType)
  349. {
  350. var type = (dataType ?? "").ToLowerInvariant();
  351. return type is not ("text" or "blob" or "json" or "image" or "ntext" or "xml" or "geometry");
  352. }
  353. private static object? RedactCell(string column, object? value)
  354. {
  355. var name = column.ToLowerInvariant();
  356. if (name.Contains("password") || name.Contains("secret") || name.Contains("token")
  357. || name.Contains("phone") || name.Contains("mobile") || name.Contains("email") || name.Contains("idcard"))
  358. return value == null ? null : "***";
  359. return value is string text ? MdpDbSyncRules.Redact(text) : value;
  360. }
  361. private sealed class SourceColumnRow
  362. {
  363. public string Name { get; set; } = "";
  364. public string? DataType { get; set; }
  365. }
  366. }
  367. public sealed record MdpDbValidateReport(bool Ok, List<MdpConfigIssue> Errors, List<MdpConfigIssue> Warnings, DateTimeOffset CheckedAt);
  368. public sealed class MdpDbPreviewInput
  369. {
  370. public int? Limit { get; set; }
  371. public string? Reason { get; set; }
  372. }
  373. public sealed class MdpDbRunInput
  374. {
  375. public string? Mode { get; set; }
  376. public string? Reason { get; set; }
  377. }
  378. public sealed class MdpDbResetCursorInput
  379. {
  380. public string? ExpectedOldCursor { get; set; }
  381. public string? NewCursor { get; set; }
  382. public string? Reason { get; set; }
  383. }