DbSyncIsolatedAcceptanceTests.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. using System.Text.RegularExpressions;
  2. using Admin.NET.Core;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform;
  4. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  5. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  6. using Microsoft.Extensions.Caching.Memory;
  7. using Microsoft.Extensions.Logging.Abstractions;
  8. using SqlSugar;
  9. using Xunit;
  10. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  11. /// <summary>
  12. /// 隔离验收:只碰 DBSYNC_TEST_MYSQL / DBSYNC_TEST_SQLSERVER 与 dbsync_test_po。
  13. /// 默认跳过。设置 AIDOP_DBSYNC_ACCEPT=1 后执行。
  14. /// </summary>
  15. public class DbSyncIsolatedAcceptanceTests
  16. {
  17. private const long MysqlTenant = 990594001;
  18. private const long SqlServerTenant = 990594002;
  19. [Fact]
  20. public async Task Isolated_sources_cover_keyset_update_full_and_reset()
  21. {
  22. if (Environment.GetEnvironmentVariable("AIDOP_DBSYNC_ACCEPT") != "1")
  23. return;
  24. var root = FindRepoRoot();
  25. using var db = OpenAidopdev(root);
  26. await RunSourceAsync(db, "DBSYNC_TEST_MYSQL", MysqlTenant, null);
  27. await RunNeutralProjectionAsync(db);
  28. await PrepareSqlServerSourceAsync(db, root);
  29. await RunSourceAsync(db, "DBSYNC_TEST_SQLSERVER", SqlServerTenant, OpenSqlServer(db, root));
  30. }
  31. private static async Task RunNeutralProjectionAsync(ISqlSugarClient db)
  32. {
  33. const string entityCode = "DBSYNC_TEST_S3_PURCHASE";
  34. var source = await db.Queryable<MdpSource>()
  35. .Where(x => x.SourceCode == "DBSYNC_TEST_MYSQL")
  36. .FirstAsync();
  37. var neutral = MdpSourceIdentity.NeutralCode(source.SystemCode, source.SourceCode);
  38. var oldIds = await db.Queryable<MdpEntity>()
  39. .Where(x => x.TenantId == MysqlTenant && x.EntityCode == entityCode)
  40. .Select(x => x.Id)
  41. .ToListAsync();
  42. if (oldIds.Count > 0)
  43. await db.Deleteable<MdpFieldMapping>().Where(x => oldIds.Contains(x.EntityId)).ExecuteCommandAsync();
  44. await db.Deleteable<MdpEntity>()
  45. .Where(x => x.TenantId == MysqlTenant && x.EntityCode == entityCode)
  46. .ExecuteCommandAsync();
  47. var now = DateTime.Now;
  48. var entity = new MdpEntity
  49. {
  50. TenantId = MysqlTenant,
  51. SourceId = source.Id,
  52. EntityCode = entityCode,
  53. EntityName = "隔离验收采购订单",
  54. EntityType = "TABLE",
  55. SourceTableName = "dbsync_test_po",
  56. TransformSourceTable = "S3_PURCHASE_ORDER",
  57. TargetTableName = "mdp_stg_purchase_order",
  58. SyncMode = "FULL",
  59. TieBreakerColumn = "id",
  60. BizKeyExpr = "PoNo,PoLine",
  61. BatchSize = 100,
  62. JobId = "DBSYNC_TEST_S3",
  63. Status = 1,
  64. CreateTime = now,
  65. UpdateTime = now
  66. };
  67. entity.Id = await db.Insertable(entity).ExecuteReturnBigIdentityAsync();
  68. await db.Insertable(new List<MdpFieldMapping>
  69. {
  70. new() { EntityId = entity.Id, SourceField = "bill_no", TargetField = "PoNo", FieldType = "DIRECT", IsRequired = 1, SortOrder = 10, CreateTime = now },
  71. new() { EntityId = entity.Id, SourceField = "line_no", TargetField = "PoLine", FieldType = "DIRECT", IsRequired = 1, SortOrder = 20, CreateTime = now },
  72. new() { EntityId = entity.Id, SourceField = "qty", TargetField = "OrderQty", FieldType = "DIRECT", IsRequired = 1, SortOrder = 30, CreateTime = now },
  73. new() { EntityId = entity.Id, SourceField = "update_time", TargetField = "OrderDate", FieldType = "DIRECT", IsRequired = 1, SortOrder = 40, CreateTime = now },
  74. new() { EntityId = entity.Id, SourceField = "update_time", TargetField = "DueDate", FieldType = "DIRECT", IsRequired = 1, SortOrder = 50, CreateTime = now },
  75. new() { EntityId = entity.Id, SourceField = "", TargetField = "SupplierCode", FieldType = "CONST", ConstValue = "TEST_SUPPLIER", SortOrder = 60, CreateTime = now },
  76. new() { EntityId = entity.Id, SourceField = "", TargetField = "ItemCode", FieldType = "CONST", ConstValue = "TEST_ITEM", SortOrder = 70, CreateTime = now }
  77. }).ExecuteCommandAsync();
  78. await db.Ado.ExecuteCommandAsync(
  79. """
  80. INSERT INTO mdp_tenant_std_source
  81. (tenant_id, std_object, source_system, source_scope, remark)
  82. VALUES (@t, 'PURCHASE', @s, 'DBSYNC_ACCEPT', '隔离验收')
  83. ON DUPLICATE KEY UPDATE source_system=VALUES(source_system), source_scope=VALUES(source_scope), remark=VALUES(remark)
  84. """,
  85. new SugarParameter("@t", MysqlTenant),
  86. new SugarParameter("@s", neutral));
  87. await db.Ado.ExecuteCommandAsync(
  88. "DELETE FROM mdp_stg_purchase_order WHERE tenant_id=@t AND source_system=@s AND source_table='S3_PURCHASE_ORDER'",
  89. new SugarParameter("@t", MysqlTenant),
  90. new SugarParameter("@s", neutral));
  91. await db.Ado.ExecuteCommandAsync(
  92. "DELETE FROM mdp_std_purchase_order WHERE tenant_id=@t AND source_system=@s",
  93. new SugarParameter("@t", MysqlTenant),
  94. new SugarParameter("@s", neutral));
  95. var executor = BuildExecutor(db);
  96. var pull = await PullAllAsync(executor, db, source, entity.Id, full: true);
  97. Assert.True(pull.Written >= 29, "neutral staging written=" + pull.Written);
  98. var resolver = new MdpTenantStdSourceResolver(db);
  99. var gate = new MdpNeutralSourceGate(resolver, db, NullLoggerFactory.Instance);
  100. var projection = new InboundNeutralProjectionService(db, gate);
  101. await projection.ProjectAsync("S3_PURCHASE_ORDER", MysqlTenant, neutral);
  102. var standardRows = await db.Ado.GetIntAsync(
  103. "SELECT COUNT(1) FROM mdp_std_purchase_order WHERE tenant_id=@t AND source_system=@s",
  104. new SugarParameter("@t", MysqlTenant),
  105. new SugarParameter("@s", neutral));
  106. Assert.True(standardRows >= 29, "neutral standard rows=" + standardRows);
  107. }
  108. private static async Task RunSourceAsync(ISqlSugarClient db, string sourceCode, long tenantId, ISqlSugarClient sourceDb)
  109. {
  110. await db.Ado.ExecuteCommandAsync(
  111. "DELETE FROM mdp_stg_dbsync_test_po WHERE source_system=@s",
  112. new SugarParameter("@s", sourceCode));
  113. await db.Ado.ExecuteCommandAsync(
  114. "UPDATE mdp_entity SET last_cursor=NULL WHERE tenant_id=@t AND entity_code='DBSYNC_TEST_PO'",
  115. new SugarParameter("@t", tenantId));
  116. var entity = await db.Queryable<MdpEntity>()
  117. .Where(x => x.TenantId == tenantId && x.EntityCode == "DBSYNC_TEST_PO" && x.Status == 1)
  118. .FirstAsync();
  119. var source = await db.Queryable<MdpSource>().Where(x => x.Id == entity.SourceId).FirstAsync();
  120. var cursorBefore = entity.LastCursor;
  121. var previewDb = sourceDb ?? db;
  122. var preview = await previewDb.Ado.GetIntAsync("SELECT COUNT(1) FROM dbsync_test_po");
  123. var cursorAfterPreview = await db.Queryable<MdpEntity>().Where(x => x.Id == entity.Id).Select(x => x.LastCursor).FirstAsync();
  124. Assert.True(preview >= 30);
  125. Assert.Equal(cursorBefore, cursorAfterPreview);
  126. var executor = BuildExecutor(db);
  127. var first = await PullAllAsync(executor, db, source, entity.Id, full: false);
  128. Assert.True(first.Written >= 29, sourceCode + " written=" + first.Written);
  129. Assert.True(first.Errors >= 1, sourceCode + " errors=" + first.Errors);
  130. var staged = await db.Ado.GetIntAsync(
  131. "SELECT COUNT(1) FROM mdp_stg_dbsync_test_po WHERE source_system=@s AND source_table='dbsync_test_po'",
  132. new SugarParameter("@s", sourceCode));
  133. var keys = await db.Ado.GetIntAsync(
  134. "SELECT COUNT(DISTINCT source_biz_key) FROM mdp_stg_dbsync_test_po WHERE source_system=@s AND source_table='dbsync_test_po'",
  135. new SugarParameter("@s", sourceCode));
  136. Assert.Equal(staged, keys);
  137. await TouchRowAsync(previewDb, source.DbType);
  138. var second = await PullAllAsync(executor, db, source, entity.Id, full: false);
  139. Assert.True(second.Written >= 1, sourceCode + " update written=" + second.Written);
  140. var full = await PullAllAsync(executor, db, source, entity.Id, full: true);
  141. Assert.True(full.Read >= 30, sourceCode + " full read=" + full.Read);
  142. var current = await db.Queryable<MdpEntity>().Where(x => x.Id == entity.Id).Select(x => x.LastCursor).FirstAsync();
  143. await db.Ado.ExecuteCommandAsync(
  144. "UPDATE mdp_entity SET last_cursor=NULL, update_time=NOW() WHERE id=@id AND ((last_cursor IS NULL AND @old IS NULL) OR last_cursor=@old)",
  145. new SugarParameter("@id", entity.Id),
  146. new SugarParameter("@old", current));
  147. await db.Ado.ExecuteCommandAsync(
  148. @"INSERT INTO mdp_entity_cursor_audit
  149. (tenant_id, entity_id, entity_code, old_cursor, new_cursor, action_type, reason, create_time)
  150. VALUES (@t, @id, @code, @old, NULL, 'RESET', '隔离验收重置水位', NOW())",
  151. new SugarParameter("@t", tenantId),
  152. new SugarParameter("@id", entity.Id),
  153. new SugarParameter("@code", entity.EntityCode),
  154. new SugarParameter("@old", current));
  155. var third = await PullAllAsync(executor, db, source, entity.Id, full: false);
  156. Assert.True(third.Read >= 30, sourceCode + " reset read=" + third.Read);
  157. var logs = await db.Ado.GetIntAsync(
  158. "SELECT COUNT(1) FROM mdp_sync_log WHERE source_code=@s AND entity_id=@id",
  159. new SugarParameter("@s", sourceCode),
  160. new SugarParameter("@id", entity.Id));
  161. Assert.True(logs >= 3, sourceCode + " logs=" + logs);
  162. }
  163. private static async Task TouchRowAsync(ISqlSugarClient sourceDb, string dbType)
  164. {
  165. var sql = dbType != null && dbType.Replace("_", "").Contains("SQLSERVER", StringComparison.OrdinalIgnoreCase)
  166. ? "UPDATE dbsync_test_po SET qty=qty+1, update_time=DATEADD(second,1,(SELECT MAX(update_time) FROM dbsync_test_po)) WHERE id=2"
  167. : "UPDATE dbsync_test_po SET qty=qty+1, update_time=(SELECT next_time FROM (SELECT DATE_ADD(MAX(update_time), INTERVAL 1 SECOND) next_time FROM dbsync_test_po) x) WHERE id=2";
  168. await sourceDb.Ado.ExecuteCommandAsync(sql);
  169. }
  170. private static async Task<(int Read, int Written, int Errors)> PullAllAsync(
  171. MdpDbPullExecutor executor, ISqlSugarClient db, MdpSource source, long entityId, bool full)
  172. {
  173. var read = 0;
  174. var written = 0;
  175. var errors = 0;
  176. var ctx = new MdpPullContext
  177. {
  178. TenantId = source.SourceCode == "DBSYNC_TEST_SQLSERVER" ? SqlServerTenant : MysqlTenant,
  179. FullRefresh = full,
  180. SyncWindowType = full ? "FULL" : "INCR",
  181. RequireConfiguredDbSync = true,
  182. TriggerType = "MANUAL",
  183. BatchId = "DBSYNC_ACCEPT_" + DateTime.Now.ToString("yyyyMMddHHmmssfff")
  184. };
  185. for (var page = 0; page < 20; page++)
  186. {
  187. var entity = await db.Queryable<MdpEntity>().Where(x => x.Id == entityId).FirstAsync();
  188. if (!full)
  189. {
  190. ctx.UseKeysetCursor = false;
  191. ctx.CursorValue = null;
  192. ctx.TieBreakerValue = null;
  193. ctx.Offset = 0;
  194. }
  195. MdpSourcePullDispatcher.ApplyDbSyncDefaults(entity, source, ctx);
  196. var result = await executor.PullAsync(source, entity, ctx);
  197. read += result.RowsPulled;
  198. written += result.RowsWritten;
  199. errors += result.RowsError;
  200. if (result.RowsPulled <= 0 || result.RowsPulled < entity.BatchSize)
  201. break;
  202. if (full)
  203. ctx.Offset += result.RowsPulled;
  204. }
  205. return (read, written, errors);
  206. }
  207. private static MdpDbPullExecutor BuildExecutor(ISqlSugarClient db)
  208. {
  209. var passwords = new MdpSourcePasswordResolver(NullLogger<MdpSourcePasswordResolver>.Instance);
  210. var scopes = new MdpSourceScopeFactory(db, passwords, NullLogger<MdpSourceScopeFactory>.Instance);
  211. var maps = new MdpPullFieldMappingService(db, new MemoryCache(new MemoryCacheOptions()));
  212. var writer = new MdpStagingWriter(db, NullLogger<MdpStagingWriter>.Instance);
  213. return new MdpDbPullExecutor(scopes, db, writer, maps);
  214. }
  215. private static async Task PrepareSqlServerSourceAsync(ISqlSugarClient mysql, string root)
  216. {
  217. using var db = OpenSqlServer(mysql, root);
  218. await db.Ado.ExecuteCommandAsync("""
  219. IF OBJECT_ID('dbo.dbsync_test_po','U') IS NULL
  220. CREATE TABLE dbo.dbsync_test_po (
  221. id bigint NOT NULL PRIMARY KEY,
  222. bill_no nvarchar(40) NULL,
  223. line_no int NOT NULL,
  224. qty decimal(18,4) NOT NULL,
  225. update_time datetime2(3) NOT NULL,
  226. row_status nvarchar(20) NOT NULL)
  227. """);
  228. await db.Ado.ExecuteCommandAsync("DELETE FROM dbo.dbsync_test_po");
  229. await db.Ado.ExecuteCommandAsync("""
  230. INSERT INTO dbo.dbsync_test_po (id, bill_no, line_no, qty, update_time, row_status) VALUES
  231. (1,'PO-001',1,1.5,'2026-10-01T08:00:00','OPEN'),
  232. (2,'PO-002',2,2.5,'2026-10-01T08:00:00','OPEN'),
  233. (3,'PO-003',3,3.5,'2026-10-01T08:00:00','OPEN'),
  234. (4,'PO-004',4,4.5,'2026-10-01T08:00:00','OPEN'),
  235. (5,'PO-005',5,5.5,'2026-10-01T08:00:00','DELETED'),
  236. (6,'PO-006',6,6.5,'2026-10-01T08:00:00','OPEN'),
  237. (7,'PO-007',7,7.5,'2026-10-01T08:00:00','OPEN'),
  238. (8,'PO-008',8,8.5,'2026-10-01T08:00:00','OPEN'),
  239. (9,'PO-009',9,9.5,'2026-10-01T08:00:00','OPEN'),
  240. (10,'PO-010',10,10.5,'2026-10-01T08:00:00','OPEN'),
  241. (11,'PO-011',11,11.5,'2026-10-02T08:00:00','OPEN'),
  242. (12,'PO-012',12,12.5,'2026-10-02T08:00:00','OPEN'),
  243. (13,'PO-013',13,13.5,'2026-10-02T08:00:00','OPEN'),
  244. (14,'PO-014',14,14.5,'2026-10-02T08:00:00','OPEN'),
  245. (15,'PO-015',15,15.5,'2026-10-02T08:00:00','OPEN'),
  246. (16,'PO-016',16,16.5,'2026-10-02T08:00:00','OPEN'),
  247. (17,'PO-017',17,17.5,'2026-10-02T08:00:00','OPEN'),
  248. (18,'PO-018',18,18.5,'2026-10-02T08:00:00','OPEN'),
  249. (19,'PO-019',19,19.5,'2026-10-02T08:00:00','OPEN'),
  250. (20,'PO-020',20,20.5,'2026-10-02T08:00:00','OPEN'),
  251. (21,'PO-021',21,21.5,'2026-10-03T08:00:00','OPEN'),
  252. (22,'PO-022',22,22.5,'2026-10-03T08:00:00','OPEN'),
  253. (23,'PO-023',23,23.5,'2026-10-03T08:00:00','OPEN'),
  254. (24,'PO-024',24,24.5,'2026-10-03T08:00:00','OPEN'),
  255. (25,'PO-025',25,25.5,'2026-10-03T08:00:00','OPEN'),
  256. (26,'PO-026',26,26.5,'2026-10-03T08:00:00','OPEN'),
  257. (27,'PO-027',27,27.5,'2026-10-03T08:00:00','OPEN'),
  258. (28,'PO-028',28,28.5,'2026-10-03T08:00:00','OPEN'),
  259. (29,'PO-029',29,29.5,'2026-10-03T08:00:00','OPEN'),
  260. (30,NULL,30,30.5,'2026-10-03T08:00:00','OPEN')
  261. """);
  262. }
  263. private static SqlSugarClient OpenSqlServer(ISqlSugarClient mysql, string root)
  264. {
  265. var cipher = mysql.Ado.GetString("SELECT db_password_enc FROM mdp_source WHERE source_code='DBSYNC_TEST_SQLSERVER'");
  266. var key = Regex.Match(File.ReadAllText(Path.Combine(root, "server", "Admin.NET.Application", "Configuration", "App.json")),
  267. "\"PrivateKey\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value;
  268. string password;
  269. try
  270. {
  271. password = GMUtil.SM2Decrypt(key, cipher);
  272. }
  273. catch (Exception)
  274. {
  275. password = cipher;
  276. }
  277. Environment.SetEnvironmentVariable(MdpSourcePasswordResolver.EnvironmentVariableName("DBSYNC_TEST_SQLSERVER"), password);
  278. var host = mysql.Ado.GetString("SELECT db_host FROM mdp_source WHERE source_code='DBSYNC_TEST_SQLSERVER'");
  279. var port = mysql.Ado.GetInt("SELECT db_port FROM mdp_source WHERE source_code='DBSYNC_TEST_SQLSERVER'");
  280. var name = mysql.Ado.GetString("SELECT db_name FROM mdp_source WHERE source_code='DBSYNC_TEST_SQLSERVER'");
  281. var user = mysql.Ado.GetString("SELECT db_user FROM mdp_source WHERE source_code='DBSYNC_TEST_SQLSERVER'");
  282. return new SqlSugarClient(new ConnectionConfig
  283. {
  284. ConfigId = "dbsync-test-sqlserver",
  285. DbType = DbType.SqlServer,
  286. ConnectionString = $"Server={host},{port};Database={name};User Id={user};Password={password};TrustServerCertificate=true;Encrypt=false",
  287. IsAutoCloseConnection = true
  288. });
  289. }
  290. private static SqlSugarScope OpenAidopdev(string root)
  291. {
  292. var cs = File.ReadLines(Path.Combine(root, "server", "Admin.NET.Application", "Configuration", "Database.json"))
  293. .Select(x => x.Trim())
  294. .Where(x => !x.StartsWith("//") && x.Contains("\"ConnectionString\"") && x.Contains("Database=aidopdev"))
  295. .Select(x => Regex.Match(x, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  296. .First(x => !string.IsNullOrEmpty(x));
  297. return new SqlSugarScope(new ConnectionConfig
  298. {
  299. ConfigId = "dbsync-accept",
  300. DbType = DbType.MySql,
  301. ConnectionString = cs,
  302. IsAutoCloseConnection = true
  303. });
  304. }
  305. private static string FindRepoRoot()
  306. {
  307. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  308. while (dir != null)
  309. {
  310. if (File.Exists(Path.Combine(dir.FullName, "server", "Admin.NET.Application", "Configuration", "Database.json")))
  311. return dir.FullName;
  312. dir = dir.Parent;
  313. }
  314. throw new InvalidOperationException("未找到 Database.json");
  315. }
  316. }