using System.Text.RegularExpressions; using Admin.NET.Plugin.AiDOP.MaterialWarehouse; using Microsoft.Extensions.Logging.Abstractions; using SqlSugar; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// S5 库存冷链跨实例互斥锁()实库测试。 /// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。 /// /// 只碰 MySQL 咨询锁(GET_LOCK/RELEASE_LOCK/IS_USED_LOCK),**不读不写任何业务表**; /// 全部用例使用带 GUID 的一次性锁键,绝不触碰生产键 aidop:s5:inventory-inbound。 /// /// [Trait("Category", "Integration")] public class InventoryInboundLockGuardTests { private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1"; private readonly Xunit.Abstractions.ITestOutputHelper _out; public InventoryInboundLockGuardTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output; /// /// 一次性测试锁键。**必须 ≤ 64 字符** —— MySQL 8.0 对 GET_LOCK 的 user-level lock name /// 有 64 字符硬上限,超长会直接报 "Incorrect user-level lock name" 而不是返回 0, /// 表现得像「取不到锁」。故这里只取 GUID 前 8 位,长度约 30 字符,仍足够隔离并发测试。 /// (生产键 aidop:s5:inventory-inbound 为 26 字符,不受此限影响。) /// private static string NewKey(string tag) => $"aidop:t:inv-in:{tag}:{Guid.NewGuid():N}"[..30]; private static string ReadAidopdevConnectionString() { const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json"; if (!File.Exists(path)) return null; return File.ReadLines(path) .Select(l => l.Trim()) .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165")) .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value) .FirstOrDefault(v => !string.IsNullOrEmpty(v)); } /// 模拟「业务共享客户端」:与 SqlSugarSetup.cs:128 一致的 IsAutoCloseConnection=true。 private static ISqlSugarClient BuildSharedClient(string cs) => new SqlSugarScope(new ConnectionConfig { ConfigId = "aidopdev-invlock-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true, }); /// 模拟「另一个 Web 实例」:独立且不自动关闭的连接,会话全程持有。 private static SqlSugarClient BuildDedicatedClient(string cs) => new(new ConnectionConfig { DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = false, }); private static async Task IsUsedLockAsync(ISqlSugarClient probe, string key) { var dt = await probe.Ado.GetDataTableAsync( "SELECT IS_USED_LOCK(@k)", new List { new("@k", key) }); if (dt == null || dt.Rows.Count == 0) return null; var v = dt.Rows[0][0]; return v == DBNull.Value ? (long?)null : Convert.ToInt64(v); } // ===== Case 1:并发互斥 —— A 进临界区,B 进不来;A 释放后 C 能进 ===== [Fact] public async Task Case1_SecondCallerIsRejectedImmediately_ThirdSucceedsAfterRelease() { if (!Enabled) return; var cs = ReadAidopdevConnectionString(); Assert.False(string.IsNullOrEmpty(cs)); var db = BuildSharedClient(cs); var key = NewKey("case1"); var started = DateTime.UtcNow; await using (var a = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key)) { Assert.True(a.Acquired, $"acquire failed: {a.BusyReason}"); Assert.NotNull(a.OwnerConnectionId); // B:临界区内的第二个并发请求必须立即被拒,不排队 var b = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key); await using (b) { Assert.False(b.Acquired); Assert.False(string.IsNullOrEmpty(b.BusyReason)); _out.WriteLine($"B rejected: {b.BusyReason}"); } // 非阻塞语义:整段不得出现 GET_LOCK 的等待 Assert.True((DateTime.UtcNow - started).TotalSeconds < 20); } // C:A 释放后必须能进 await using var c = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key); Assert.True(c.Acquired, $"acquire failed: {c.BusyReason}"); } // ===== Case 2:回归断言 —— 任务运行中 IS_USED_LOCK 非 NULL,结束后 NULL ===== // 这正是原缺陷的判据:旧实现在共享连接上取锁,取完连接回池被 reset,IS_USED_LOCK 恒为 NULL。 [Fact] public async Task Case2_LockIsVisibleToOtherSessions_WhileHeld_AndGoneAfterRelease() { if (!Enabled) return; var cs = ReadAidopdevConnectionString(); Assert.False(string.IsNullOrEmpty(cs)); var db = BuildSharedClient(cs); var key = NewKey("case2"); // 用一条独立连接当「观察者」,模拟另一个实例来看锁在不在 using var probe = BuildDedicatedClient(cs); long? ownerWhileHeld; long? guardOwner; await using (var a = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key)) { Assert.True(a.Acquired, $"acquire failed: {a.BusyReason}"); guardOwner = a.OwnerConnectionId; // 期间在共享 db 上再跑几条普通查询(会不断借还连接),锁必须不受影响 await db.Ado.GetDataTableAsync("SELECT 1"); await db.Ado.GetDataTableAsync("SELECT 2"); ownerWhileHeld = await IsUsedLockAsync(probe, key); } var ownerAfter = await IsUsedLockAsync(probe, key); _out.WriteLine($"guardOwner={guardOwner} whileHeld={ownerWhileHeld} after={ownerAfter}"); Assert.NotNull(ownerWhileHeld); // 修复前这里是 NULL —— 缺陷复现点 Assert.Equal(guardOwner, ownerWhileHeld); // 持有者必须就是 guard 的专用连接 Assert.Null(ownerAfter); // 释放后必须干净 } // ===== Case 3:异常路径 —— 临界区内抛异常,finally(await using) 必须释放,后续可继续 ===== [Fact] public async Task Case3_ExceptionInsideCriticalSection_StillReleasesLock() { if (!Enabled) return; var cs = ReadAidopdevConnectionString(); Assert.False(string.IsNullOrEmpty(cs)); var db = BuildSharedClient(cs); var key = NewKey("case3"); using var probe = BuildDedicatedClient(cs); await Assert.ThrowsAsync(async () => { await using var a = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key); Assert.True(a.Acquired, $"acquire failed: {a.BusyReason}"); throw new InvalidOperationException("boom: 模拟 transform 中途失败"); }); // 锁必须已经释放:DB 侧看不到,且下一次能取到 Assert.Null(await IsUsedLockAsync(probe, key)); await using var next = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key); Assert.True(next.Acquired, $"acquire failed: {next.BusyReason}"); } // ===== Case 4:跨实例互斥 —— 「另一个实例」持锁时,本实例必须被拒(绕过进程内闸门) ===== [Fact] public async Task Case4_WhenAnotherInstanceHoldsLock_AcquireIsRejectedCrossInstance() { if (!Enabled) return; var cs = ReadAidopdevConnectionString(); Assert.False(string.IsNullOrEmpty(cs)); var db = BuildSharedClient(cs); var key = NewKey("case4"); // 「实例 2」:独立会话持锁不放 using var other = BuildDedicatedClient(cs); var dt = await other.Ado.GetDataTableAsync( "SELECT GET_LOCK(@k, 0)", new List { new("@k", key) }); Assert.Equal(1L, Convert.ToInt64(dt.Rows[0][0])); try { await using var mine = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key); Assert.False(mine.Acquired); Assert.Equal("cross-instance busy", mine.BusyReason); } finally { await other.Ado.ExecuteCommandAsync( "SELECT RELEASE_LOCK(@k)", new List { new("@k", key) }); } // 对方释放后本实例必须能取到 await using var after = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key); Assert.True(after.Acquired, $"acquire failed: {after.BusyReason}"); } // ===== Case 5:缺陷本体的负向留证 —— 旧写法(共享自动关闭连接)取的锁当场蒸发 ===== [Fact] public async Task Case5_LegacyPattern_OnAutoCloseSharedClient_DoesNotHoldLock() { if (!Enabled) return; var cs = ReadAidopdevConnectionString(); Assert.False(string.IsNullOrEmpty(cs)); var legacy = BuildSharedClient(cs); // IsAutoCloseConnection = true —— 旧实现 var key = NewKey("case5-legacy"); using var probe = BuildDedicatedClient(cs); // 旧实现的取锁语句 var got = await legacy.Ado.GetIntAsync( "SELECT GET_LOCK(@k, 0)", new List { new("@k", key) }); Assert.Equal(1, got); // 命令一结束连接即回池并被驱动 reset,MySQL 释放该会话咨询锁 → 其它会话看不到任何持有者 var owner = await IsUsedLockAsync(probe, key); _out.WriteLine("legacy owner after GET_LOCK = " + (owner.HasValue ? owner.Value.ToString() : "NULL")); Assert.Null(owner); // 因此另一个实例可以毫无阻碍地拿到同一把锁 —— 这就是 P0 缺陷 await using var second = await InventoryInboundLockGuard.TryAcquireAsync(legacy, NullLogger.Instance, key); Assert.True(second.Acquired, $"acquire failed: {second.BusyReason}"); } }