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}");
}
}