MdpInboundSnapshotService.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. using System.Security.Cryptography;
  2. using System.Text.RegularExpressions;
  3. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  4. using Microsoft.Extensions.Logging;
  5. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
  6. public sealed class MdpInboundSnapshotService : ITransient
  7. {
  8. private static readonly Regex TableNameRe = new(@"^[A-Za-z0-9_]+$", RegexOptions.Compiled);
  9. private const decimal DefaultDiffThreshold = 0.20m;
  10. private readonly ISqlSugarClient _db;
  11. private readonly MdpInboundAuthService _auth;
  12. private readonly ILogger<MdpInboundSnapshotService> _logger;
  13. public MdpInboundSnapshotService(
  14. ISqlSugarClient db,
  15. MdpInboundAuthService auth,
  16. ILogger<MdpInboundSnapshotService> logger)
  17. {
  18. _db = db;
  19. _auth = auth;
  20. _logger = logger;
  21. }
  22. public async Task<MdpInboundOutcome> OpenAsync(
  23. string entityCode, string accessKey, long tenantId, string clientIp, CancellationToken ct)
  24. {
  25. var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
  26. var denied = await _auth.CheckAsync(accessKey, tenantId, code, clientIp, ct);
  27. if (denied != null)
  28. return Fail(denied.Value.Status, denied.Value.Message);
  29. await ExpireOpenAsync(tenantId, accessKey, code, ct);
  30. var existing = await _db.Queryable<MdpInboundSnapshot>()
  31. .Where(s => s.TenantId == tenantId && s.AccessKey == accessKey && s.Status == "OPEN")
  32. .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
  33. .FirstAsync(ct);
  34. if (existing != null)
  35. return Fail(409, "snapshot already open");
  36. var now = DateTime.Now;
  37. var snapshotId = $"SNAP_{now:yyyyMMddHHmmss}_{RandomToken(8)}";
  38. await _db.Insertable(new MdpInboundSnapshot
  39. {
  40. SnapshotId = snapshotId,
  41. TenantId = tenantId,
  42. AccessKey = accessKey,
  43. EntityCode = code,
  44. Status = "OPEN",
  45. LastSeq = 0,
  46. RowCount = 0,
  47. OpenedAt = now,
  48. ExpireAt = now.AddHours(24),
  49. CreateTime = now,
  50. UpdateTime = now
  51. }).ExecuteCommandAsync(ct);
  52. return Ok(new { snapshotId, expireAt = now.AddHours(24), status = "OPEN" }, 201);
  53. }
  54. public async Task EnsureAcceptAsync(
  55. string snapshotId, string accessKey, long tenantId, string entityCode, int? seq, CancellationToken ct)
  56. {
  57. if (string.IsNullOrWhiteSpace(snapshotId))
  58. return;
  59. if (seq is null or < 1)
  60. throw new MdpInboundBatchException(400, "seq required");
  61. var snap = await LoadAsync(snapshotId, accessKey, tenantId, entityCode, ct)
  62. ?? throw new MdpInboundBatchException(400, "snapshot not found");
  63. if (snap.ExpireAt < DateTime.Now)
  64. {
  65. await MarkStatusAsync(snap.Id, "EXPIRED", ct);
  66. throw new MdpInboundBatchException(400, "snapshot expired");
  67. }
  68. if (!string.Equals(snap.Status, "OPEN", StringComparison.OrdinalIgnoreCase))
  69. throw new MdpInboundBatchException(409, "snapshot not open");
  70. if (seq.Value != snap.LastSeq + 1)
  71. {
  72. _logger.LogWarning("inbound snapshot seq gap snapshot={Snap} expected={Exp} got={Got}",
  73. snapshotId, snap.LastSeq + 1, seq);
  74. throw new MdpInboundBatchException(400, "seq gap");
  75. }
  76. }
  77. public async Task AdvanceAsync(string snapshotId, int seq, int rowCount, CancellationToken ct)
  78. {
  79. if (string.IsNullOrWhiteSpace(snapshotId))
  80. return;
  81. var n = await _db.Ado.ExecuteCommandAsync(
  82. """
  83. UPDATE mdp_inbound_snapshot
  84. SET last_seq=@seq, row_count=row_count+@rows, update_time=@now
  85. WHERE snapshot_id=@id AND status='OPEN' AND last_seq=@prev
  86. """,
  87. new SugarParameter("@seq", seq),
  88. new SugarParameter("@rows", rowCount),
  89. new SugarParameter("@now", DateTime.Now),
  90. new SugarParameter("@id", snapshotId),
  91. new SugarParameter("@prev", seq - 1));
  92. if (n != 1)
  93. throw new InvalidOperationException("snapshot seq advance failed");
  94. }
  95. public async Task<MdpInboundOutcome> CommitAsync(
  96. string entityCode, string snapshotId, string accessKey, long tenantId, string clientIp,
  97. bool force, CancellationToken ct)
  98. {
  99. var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
  100. var denied = await _auth.CheckAsync(accessKey, tenantId, code, clientIp, ct);
  101. if (denied != null)
  102. return Fail(denied.Value.Status, denied.Value.Message);
  103. var grant = await _auth.GetGrantAsync(accessKey, code, ct);
  104. if (grant == null)
  105. return Fail(403, "entity not authorized");
  106. var entity = await _db.Queryable<MdpEntity>()
  107. .Where(e => e.InboundEnabled == 1)
  108. .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
  109. .FirstAsync(ct);
  110. if (entity == null)
  111. return Fail(403, "entity not authorized");
  112. var snap = await LoadAsync(snapshotId, accessKey, tenantId, code, ct)
  113. ?? throw new MdpInboundBatchException(400, "snapshot not found");
  114. if (snap.ExpireAt < DateTime.Now)
  115. {
  116. await MarkStatusAsync(snap.Id, "EXPIRED", ct);
  117. return Fail(400, "snapshot expired");
  118. }
  119. if (force)
  120. {
  121. if (!string.Equals(snap.Status, "DIFF_BLOCKED", StringComparison.OrdinalIgnoreCase))
  122. return Fail(409, "force only when DIFF_BLOCKED");
  123. }
  124. else if (!string.Equals(snap.Status, "OPEN", StringComparison.OrdinalIgnoreCase))
  125. {
  126. return Fail(409, "snapshot not open");
  127. }
  128. if (string.IsNullOrWhiteSpace(entity.TargetTableName) || !TableNameRe.IsMatch(entity.TargetTableName))
  129. return Fail(500, "illegal target_table_name");
  130. var sourceTable = string.IsNullOrWhiteSpace(entity.SourceTableName) ? entity.EntityCode : entity.SourceTableName;
  131. var batchIds = await _db.Queryable<MdpInboundRequest>()
  132. .Where(r => r.SnapshotId == snapshotId && r.Status == "COMMITTED")
  133. .Select(r => r.SyncBatchId)
  134. .ToListAsync(ct);
  135. var snapshotKeys = new HashSet<string>(StringComparer.Ordinal);
  136. if (batchIds.Count > 0)
  137. {
  138. var keys = await _db.Ado.SqlQueryAsync<string>(
  139. $"""
  140. SELECT source_biz_key FROM `{entity.TargetTableName}`
  141. WHERE tenant_id=@t AND source_system=@sys AND source_table=@st
  142. AND sync_batch_id IN ({string.Join(",", batchIds.Select((_, i) => "@b" + i))})
  143. """,
  144. new[]
  145. {
  146. new SugarParameter("@t", tenantId),
  147. new SugarParameter("@sys", grant.SourceCode),
  148. new SugarParameter("@st", sourceTable)
  149. }.Concat(batchIds.Select((b, i) => new SugarParameter("@b" + i, b))).ToArray());
  150. foreach (var k in keys.Where(x => !string.IsNullOrWhiteSpace(x)))
  151. snapshotKeys.Add(k);
  152. }
  153. var allKeys = await _db.Ado.SqlQueryAsync<string>(
  154. $"""
  155. SELECT source_biz_key FROM `{entity.TargetTableName}`
  156. WHERE tenant_id=@t AND source_system=@sys AND source_table=@st
  157. """,
  158. new SugarParameter("@t", tenantId),
  159. new SugarParameter("@sys", grant.SourceCode),
  160. new SugarParameter("@st", sourceTable));
  161. var missing = allKeys.Where(k => !string.IsNullOrWhiteSpace(k) && !snapshotKeys.Contains(k)).Distinct().ToList();
  162. var total = Math.Max(allKeys.Count, 1);
  163. var ratio = (decimal)missing.Count / total;
  164. if (!force && ratio > DefaultDiffThreshold)
  165. {
  166. await _db.Updateable<MdpInboundSnapshot>()
  167. .SetColumns(s => new MdpInboundSnapshot
  168. {
  169. Status = "DIFF_BLOCKED",
  170. DiffMissing = missing.Count,
  171. DiffRatio = ratio,
  172. UpdateTime = DateTime.Now
  173. })
  174. .Where(s => s.Id == snap.Id)
  175. .ExecuteCommandAsync(ct);
  176. _logger.LogWarning("inbound snapshot DIFF_BLOCKED snap={Snap} missing={N} ratio={R}",
  177. snapshotId, missing.Count, ratio);
  178. return Fail(409, "diff threshold exceeded");
  179. }
  180. var tran = await _db.Ado.UseTranAsync(async () =>
  181. {
  182. if (missing.Count > 0)
  183. {
  184. var inParams = missing.Select((k, i) => new SugarParameter("@k" + i, k)).ToArray();
  185. var emptyObj = "{}";
  186. await _db.Ado.ExecuteCommandAsync(
  187. $"""
  188. UPDATE `{entity.TargetTableName}`
  189. SET raw_data = JSON_SET(IFNULL(raw_data, '{emptyObj}'), '$.is_deleted', 1),
  190. process_status='PENDING',
  191. update_time=@now
  192. WHERE tenant_id=@t AND source_system=@sys AND source_table=@st
  193. AND source_biz_key IN ({string.Join(",", missing.Select((_, i) => "@k" + i))})
  194. """,
  195. new[]
  196. {
  197. new SugarParameter("@now", DateTime.Now),
  198. new SugarParameter("@t", tenantId),
  199. new SugarParameter("@sys", grant.SourceCode),
  200. new SugarParameter("@st", sourceTable)
  201. }.Concat(inParams).ToArray());
  202. }
  203. await _db.Updateable<MdpInboundSnapshot>()
  204. .SetColumns(s => new MdpInboundSnapshot
  205. {
  206. Status = "COMMITTED",
  207. DiffMissing = missing.Count,
  208. DiffRatio = ratio,
  209. CommittedAt = DateTime.Now,
  210. UpdateTime = DateTime.Now
  211. })
  212. .Where(s => s.Id == snap.Id)
  213. .ExecuteCommandAsync(ct);
  214. });
  215. if (!tran.IsSuccess)
  216. {
  217. _logger.LogError(tran.ErrorException, "inbound snapshot commit failed snap={Snap}", snapshotId);
  218. return Fail(500, "snapshot commit failed");
  219. }
  220. return Ok(new
  221. {
  222. snapshotId,
  223. status = "COMMITTED",
  224. diffMissing = missing.Count,
  225. diffRatio = ratio,
  226. force
  227. });
  228. }
  229. public async Task ExpireStaleOpenAsync(CancellationToken ct)
  230. {
  231. await _db.Updateable<MdpInboundSnapshot>()
  232. .SetColumns(s => new MdpInboundSnapshot { Status = "EXPIRED", UpdateTime = DateTime.Now })
  233. .Where(s => s.Status == "OPEN" && s.ExpireAt < DateTime.Now)
  234. .ExecuteCommandAsync(ct);
  235. }
  236. private async Task ExpireOpenAsync(long tenantId, string accessKey, string entityCode, CancellationToken ct)
  237. {
  238. await _db.Updateable<MdpInboundSnapshot>()
  239. .SetColumns(s => new MdpInboundSnapshot { Status = "EXPIRED", UpdateTime = DateTime.Now })
  240. .Where(s => s.TenantId == tenantId && s.AccessKey == accessKey && s.Status == "OPEN" && s.ExpireAt < DateTime.Now)
  241. .Where("UPPER(entity_code) = @code", new SugarParameter("@code", entityCode))
  242. .ExecuteCommandAsync(ct);
  243. }
  244. private Task<MdpInboundSnapshot> LoadAsync(
  245. string snapshotId, string accessKey, long tenantId, string entityCode, CancellationToken ct) =>
  246. _db.Queryable<MdpInboundSnapshot>()
  247. .Where(s => s.SnapshotId == snapshotId && s.AccessKey == accessKey && s.TenantId == tenantId)
  248. .Where("UPPER(entity_code) = @code", new SugarParameter("@code", (entityCode ?? "").ToUpperInvariant()))
  249. .FirstAsync(ct);
  250. private Task<int> MarkStatusAsync(long id, string status, CancellationToken ct) =>
  251. _db.Updateable<MdpInboundSnapshot>()
  252. .SetColumns(s => new MdpInboundSnapshot { Status = status, UpdateTime = DateTime.Now })
  253. .Where(s => s.Id == id)
  254. .ExecuteCommandAsync(ct);
  255. private static string RandomToken(int len)
  256. {
  257. var bytes = RandomNumberGenerator.GetBytes(len);
  258. return Convert.ToHexString(bytes)[..len];
  259. }
  260. private static MdpInboundOutcome Fail(int status, string message) =>
  261. new() { HttpStatus = status, Body = new { code = status, message, data = (object)null } };
  262. private static MdpInboundOutcome Ok(object data, int http = 200) =>
  263. new() { HttpStatus = http, Body = new { code = 0, message = "ok", data } };
  264. }