Преглед на файлове

fix(s5): enforce inventory inbound mutual exclusion

S5 库存冷链的跨实例互斥锁此前完全失效,两个实例可同时执行 reconcile
(含 mdp_std_inventory 的 DELETE+INSERT 全量替换),DELETE 窗口期内
另一侧会读到空的或半成品的标准层。

根因(已实证):GET_LOCK 跑在共享的 SqlSugar 客户端上,而主库配置
IsAutoCloseConnection=true(SqlSugarSetup.cs:128)+ 连接池化
(Database.json:16),每条命令执行完连接立即归还,MySqlConnector 归还时
发 COM_RESET_CONNECTION,MySQL 随即释放该会话的咨询锁。
在 dev 库(MySQL 8.0.31)用隔离探测键实测:
  GET_LOCK -> 1 / IS_USED_LOCK 同 session -> 4244812 /
  resetconnection / IS_USED_LOCK -> NULL
即「取锁 → 连接归池 → 锁蒸发」,与线上观测到的 IS_USED_LOCK 恒 NULL 吻合;
且随后的 RELEASE_LOCK 多半跑在另一条连接上,以非属主身份返回 0、什么都没释放。

另发现一个独立缺陷:TransformInventoryStdFromStgAsync 会为每个租户写
mdp_std_inventory,却一把锁都没取(连坏的那把都没有)。该方法是标准层
租户隔离批次新增时漏加的,本次补上。

修复 InventoryInboundLockGuard(新增):
- 进程内 SemaphoreSlim 闸门(沿用 S3MdpSyncTransformService.cs:18 既有模式)
- 专用 IsAutoCloseConnection=false 连接持咨询锁(沿用
  KpiSqlReadOnlyExecutor.cs:95-102 的隔离连接模式),保证
  取锁连接 == 全程持有连接 == 释放连接
- 两层均 timeout=0 非阻塞,第二个调用方立即得到 Skipped=true/"lock busy",不排队
- 取锁后自证 IS_USED_LOCK == CONNECTION_ID(),不成立则 fail closed 并报错,
  作为「有人把 IsAutoCloseConnection 改回去」的回归探针
- await using + DisposeAsync 内 finally 释放信号量,异常路径必定释放

接入:RunAsync / TransformTransStdFromStgAsync / TransformInventoryStdFromStgAsync /
DailyRecon 四处,删除原手写 try/finally 与失效的 TryAcquireLockAsync/ReleaseLockAsync。

测试(5 个实库集成用例,AIDOP_IT=1,只操作一次性 GUID 测试键,不碰业务表):
  Case1 A 持锁 / B 立即被拒 / A 释放后 C 可进,全程 <20s 证明非阻塞
  Case2 持锁期间独立观察连接看到属主 == guard 专用连接,释放后为 NULL;
        期间在共享客户端上反复跑查询制造借还churn —— 旧实现下仅此即可弄丢锁
  Case3 临界区内抛异常 → 锁与信号量均释放,后续可再取
  Case4 模拟另一实例持锁 → 本地取锁被拒且 reason=cross-instance busy
  Case5 负向对照:旧写法 GET_LOCK 返回 1 但观察者看到 IS_USED_LOCK=NULL
实测 5/5 通过,Case2 输出 guardOwner=4245860 whileHeld=4245860 after=NULL。

集成时另修测试键长度:MySQL 8.0 对 user-level lock name 有 64 字符硬上限,
超长直接报 Incorrect user-level lock name(表现得像取不到锁),
测试键改为 30 字符。生产键 aidop:s5:inventory-inbound 为 26 字符,不受影响。
YY968XX преди 11 часа
родител
ревизия
6e3f15bf59

+ 223 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryInboundLockGuardTests.cs

@@ -0,0 +1,223 @@
+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;
+
+/// <summary>
+/// S5 库存冷链跨实例互斥锁(<see cref="InventoryInboundLockGuard"/>)实库测试。
+/// <para>**门禁**:仅当环境变量 AIDOP_IT=1 时执行。</para>
+/// <para>
+/// 只碰 MySQL 咨询锁(GET_LOCK/RELEASE_LOCK/IS_USED_LOCK),**不读不写任何业务表**;
+/// 全部用例使用带 GUID 的一次性锁键,绝不触碰生产键 <c>aidop:s5:inventory-inbound</c>。
+/// </para>
+/// </summary>
+[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;
+
+    /// <summary>
+    /// 一次性测试锁键。**必须 ≤ 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 字符,不受此限影响。)
+    /// </summary>
+    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));
+    }
+
+    /// <summary>模拟「业务共享客户端」:与 SqlSugarSetup.cs:128 一致的 IsAutoCloseConnection=true。</summary>
+    private static ISqlSugarClient BuildSharedClient(string cs) =>
+        new SqlSugarScope(new ConnectionConfig
+        {
+            ConfigId = "aidopdev-invlock-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
+        });
+
+    /// <summary>模拟「另一个 Web 实例」:独立且不自动关闭的连接,会话全程持有。</summary>
+    private static SqlSugarClient BuildDedicatedClient(string cs) =>
+        new(new ConnectionConfig
+        {
+            DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = false,
+        });
+
+    private static async Task<long?> IsUsedLockAsync(ISqlSugarClient probe, string key)
+    {
+        var dt = await probe.Ado.GetDataTableAsync(
+            "SELECT IS_USED_LOCK(@k)", new List<SugarParameter> { 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<InvalidOperationException>(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<SugarParameter> { 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<SugarParameter> { 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<SugarParameter> { 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}");
+    }
+}

+ 16 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryInboundAdminService.cs

@@ -11,15 +11,18 @@ public sealed class InventoryInboundAdminService : IDynamicApiController, ITrans
 {
     private readonly InventoryMdpSyncService _sync;
     private readonly InventoryReconService _recon;
+    private readonly ISqlSugarClient _db;
     private readonly ILogger _logger;
 
     public InventoryInboundAdminService(
         InventoryMdpSyncService sync,
         InventoryReconService recon,
+        ISqlSugarClient db,
         ILoggerFactory loggerFactory)
     {
         _sync = sync;
         _recon = recon;
+        _db = db;
         _logger = loggerFactory.CreateLogger(nameof(InventoryInboundAdminService));
     }
 
@@ -72,13 +75,25 @@ public sealed class InventoryInboundAdminService : IDynamicApiController, ITrans
         return await _sync.TransformInventoryStdFromStgAsync(batchId, ct);
     }
 
+    /// <summary>
+    /// 日终对账与 bootstrap / reconcile / transform 共享同一互斥闸门:
+    /// 对账读 mdp_std_inventory 并写差异,跑批期间的中间态会产生假差异,必须排他。
+    /// 锁在本入口而不是 InventoryReconService 内部,避免与 InventoryMdpSyncService 的自锁重入。
+    /// </summary>
     [DisplayName("库存日终对账(手工)")]
     [HttpPost("daily-recon")]
     [ApiDescriptionSettings(Name = "S5InventoryDailyRecon")]
     public async Task<object> DailyRecon(CancellationToken ct)
     {
         _logger.LogInformation("[S5InventoryInbound] daily-recon requested");
+        await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: ct);
+        if (!guard.Acquired)
+        {
+            _logger.LogWarning("[S5InventoryInbound] daily-recon 跳过,互斥锁占用 reason={Reason}", guard.BusyReason);
+            return new { diffs = 0, skipped = true, message = "lock busy" };
+        }
+
         var diffs = await _recon.RunDailyReconAsync(ct);
-        return new { diffs, message = diffs == 0 ? "clean" : "diffs written" };
+        return new { diffs, skipped = false, message = diffs == 0 ? "clean" : "diffs written" };
     }
 }

+ 205 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryInboundLockGuard.cs

@@ -0,0 +1,205 @@
+using System.Data;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 库存冷链(InventoryInbound)互斥闸门:进程内 + 跨实例双层。
+/// <para>
+/// <b>为什么不能直接用共享 <c>ISqlSugarClient</c> 跑 GET_LOCK</b>:主库 SqlSugar 配置
+/// <c>IsAutoCloseConnection = true</c>(Admin.NET.Core/SqlSugar/SqlSugarSetup.cs:128),
+/// 每条命令执行完连接立即归还 MySqlConnector 连接池;归还时驱动默认发送
+/// <c>COM_RESET_CONNECTION</c>,而 MySQL 明确规定该命令会释放 <c>GET_LOCK()</c> 取得的咨询锁。
+/// 于是「取锁 → 归还连接 → 锁当场没了」,`IS_USED_LOCK` 恒为 NULL,跨实例互斥形同虚设;
+/// 且后续 RELEASE_LOCK 多半跑在另一条连接上(非持有者,返回 0,什么都没释放)。
+/// </para>
+/// <para>
+/// <b>本类的不变量</b>:取锁的 connection == 任务全程保持打开的 connection == 释放锁的 connection。
+/// 做法是另开一个 <c>IsAutoCloseConnection = false</c> 的专用 <see cref="SqlSugarClient"/>
+/// (与 <c>KpiSqlReadOnlyExecutor</c> 的专用隔离连接同款),它在 Dispose 前不会把连接还回池子。
+/// </para>
+/// <para>
+/// <b>非阻塞</b>:进程内闸门 <c>WaitAsync(0)</c>、DB 锁 <c>GET_LOCK(key, 0)</c>,
+/// 第二个并发请求立即得到「未取到」,由调用方返回 <c>Skipped=true / "lock busy"</c>,绝不排队等待。
+/// </para>
+/// <para>
+/// <b>释放</b>:<see cref="DisposeAsync"/> 先 RELEASE_LOCK,再 Dispose 专用连接。
+/// 即使 RELEASE_LOCK 因故失败,连接关闭 / 归还池时的 reset 也会兜底释放该会话持有的咨询锁。
+/// 调用方必须用 <c>await using</c>,异常路径才会走到释放。
+/// </para>
+/// </summary>
+public sealed class InventoryInboundLockGuard : IAsyncDisposable
+{
+    /// <summary>跨实例咨询锁名(MySQL GET_LOCK 键)。</summary>
+    public const string LockKey = "aidop:s5:inventory-inbound";
+
+    /// <summary>取锁等待毫秒数:0 = 立即返回,不排队。</summary>
+    private const int NoWaitMilliseconds = 0;
+
+    /// <summary>锁语句自身的命令超时(秒);只跑 GET_LOCK/RELEASE_LOCK,不需要长超时。</summary>
+    private const int LockCommandTimeoutSeconds = 10;
+
+    /// <summary>
+    /// 进程内闸门。单实例内多个 HTTP 请求 / Job 并发时先在这里被挡掉,
+    /// 不必每次都去 MySQL 抢咨询锁;同时保证同进程内不会有两条并发链路各自持有一条锁连接。
+    /// </summary>
+    private static readonly SemaphoreSlim ProcessGate = new(1, 1);
+
+    private readonly SqlSugarClient _lockDb;
+    private readonly ILogger _logger;
+    private readonly string _lockKey;
+    private bool _processGateHeld;
+    private bool _dbLockHeld;
+    private bool _disposed;
+
+    /// <summary>是否成功拿到互斥权(进程内闸门 + 跨实例咨询锁都拿到才为 true)。</summary>
+    public bool Acquired { get; private set; }
+
+    /// <summary>持锁会话的 MySQL CONNECTION_ID(),仅用于排障/断言,未取到锁时为 null。</summary>
+    public long? OwnerConnectionId { get; private set; }
+
+    /// <summary>未取到锁时的原因,便于日志区分「同进程忙」与「其它实例忙」。</summary>
+    public string BusyReason { get; private set; }
+
+    private InventoryInboundLockGuard(SqlSugarClient lockDb, ILogger logger, string lockKey)
+    {
+        _lockDb = lockDb;
+        _logger = logger;
+        _lockKey = lockKey;
+    }
+
+    /// <summary>
+    /// 尝试取锁;无论成功与否都返回一个 guard,调用方以 <see cref="Acquired"/> 判定,
+    /// 并始终以 <c>await using</c> 持有(未取到锁的 guard 释放时什么都不做)。
+    /// </summary>
+    /// <param name="db">主库客户端,仅用于读取连接配置,不在其上取锁。</param>
+    /// <param name="logger">日志。</param>
+    /// <param name="lockKey">咨询锁键;默认 <see cref="LockKey"/>,测试可传独立键避免打扰生产链路。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    public static async Task<InventoryInboundLockGuard> TryAcquireAsync(
+        ISqlSugarClient db,
+        ILogger logger,
+        string lockKey = LockKey,
+        CancellationToken cancellationToken = default)
+    {
+        if (db == null) throw new ArgumentNullException(nameof(db));
+
+        var main = db.CurrentConnectionConfig;
+        var lockDb = new SqlSugarClient(new ConnectionConfig
+        {
+            ConnectionString = main.ConnectionString,
+            DbType = main.DbType,
+            // 关键:专用连接不自动关闭,GET_LOCK 与 RELEASE_LOCK 之间连接不回池、会话不重置
+            IsAutoCloseConnection = false,
+        });
+
+        var guard = new InventoryInboundLockGuard(lockDb, logger, lockKey);
+
+        // 第一层:进程内闸门,非阻塞
+        var gate = await ProcessGate.WaitAsync(NoWaitMilliseconds, cancellationToken);
+        if (!gate)
+        {
+            guard.BusyReason = "in-process busy";
+            lockDb.Dispose();
+            guard._disposed = true; // 什么都没持有,调用方的 await using 直接空转
+            return guard;
+        }
+        guard._processGateHeld = true;
+
+        try
+        {
+            lockDb.Ado.CommandTimeOut = LockCommandTimeoutSeconds;
+
+            // 第二层:MySQL 咨询锁,非阻塞(timeout=0)
+            var got = await ScalarAsync(lockDb, "SELECT GET_LOCK(@k, 0)",
+                new List<SugarParameter> { new("@k", lockKey) });
+            // GET_LOCK 返回 1=取到;0=超时未取到;NULL=出错/被中断
+            if (got is null || Convert.ToInt64(got) != 1L)
+            {
+                guard.BusyReason = got is null ? "GET_LOCK returned NULL" : "cross-instance busy";
+                await guard.DisposeAsync();
+                return guard;
+            }
+            guard._dbLockHeld = true;
+
+            // 同会话自证:持有者必须就是本连接。若不成立说明连接被换过(即本缺陷的回归),fail closed。
+            var selfCheck = await lockDb.Ado.GetDataTableAsync(
+                "SELECT IS_USED_LOCK(@k) AS owner_conn, CONNECTION_ID() AS my_conn",
+                new List<SugarParameter> { new("@k", lockKey) });
+            var owner = CellOrNull(selfCheck, 0);
+            var me = CellOrNull(selfCheck, 1);
+            if (owner is null || me is null || Convert.ToInt64(owner) != Convert.ToInt64(me))
+            {
+                logger?.LogError(
+                    "[InventoryInboundLock] 取锁后自证失败:owner={Owner} me={Me},判定跨实例互斥不可信,放弃本轮",
+                    owner, me);
+                guard.BusyReason = "lock self-check failed";
+                await guard.DisposeAsync();
+                return guard;
+            }
+
+            guard.OwnerConnectionId = Convert.ToInt64(me);
+            guard.Acquired = true;
+            logger?.LogInformation(
+                "[InventoryInboundLock] acquired key={Key} conn={Conn}", lockKey, guard.OwnerConnectionId);
+            return guard;
+        }
+        catch (Exception ex)
+        {
+            logger?.LogError(ex, "[InventoryInboundLock] 取锁失败 key={Key}", lockKey);
+            await guard.DisposeAsync();
+            guard.BusyReason ??= "acquire failed: " + ex.Message;
+            return guard;
+        }
+    }
+
+    /// <summary>释放:先 RELEASE_LOCK(同一连接),再关闭专用连接兜底。</summary>
+    public async ValueTask DisposeAsync()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        Acquired = false;
+
+        try
+        {
+            if (_dbLockHeld)
+            {
+                try
+                {
+                    await _lockDb.Ado.ExecuteCommandAsync(
+                        "SELECT RELEASE_LOCK(@k)", new List<SugarParameter> { new("@k", _lockKey) });
+                }
+                catch (Exception ex)
+                {
+                    // 连接 Dispose 时会话结束 / 连接归还池触发 reset,MySQL 会自动释放该会话的咨询锁,故这里只记录
+                    _logger?.LogWarning(ex, "[InventoryInboundLock] RELEASE_LOCK 失败,改由连接关闭兜底 key={Key}", _lockKey);
+                }
+                _dbLockHeld = false;
+            }
+
+            try { _lockDb.Dispose(); }
+            catch (Exception ex) { _logger?.LogWarning(ex, "[InventoryInboundLock] 专用锁连接释放异常"); }
+        }
+        finally
+        {
+            if (_processGateHeld)
+            {
+                _processGateHeld = false;
+                ProcessGate.Release();
+            }
+        }
+    }
+
+    private static async Task<object> ScalarAsync(SqlSugarClient db, string sql, List<SugarParameter> pars)
+    {
+        DataTable dt = await db.Ado.GetDataTableAsync(sql, pars);
+        return CellOrNull(dt, 0);
+    }
+
+    private static object CellOrNull(DataTable dt, int columnIndex)
+    {
+        if (dt == null || dt.Rows.Count == 0 || dt.Columns.Count <= columnIndex) return null;
+        var v = dt.Rows[0][columnIndex];
+        return v == DBNull.Value ? null : v;
+    }
+}

+ 130 - 131
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMdpSyncService.cs

@@ -13,7 +13,8 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// </summary>
 public sealed class InventoryMdpSyncService : ITransient
 {
-    public const string LockKey = "aidop:s5:inventory-inbound";
+    /// <summary>跨实例互斥锁键;真正的取/放锁生命周期见 <see cref="InventoryInboundLockGuard"/>。</summary>
+    public const string LockKey = InventoryInboundLockGuard.LockKey;
     private const string LocationEntity = "S5_LOCATION_DETAIL_SQLSERVER";
     private const string TransEntity = "S5_INV_TRANS_HIST_SQLSERVER";
     private const string SourceCodeDefault = "DOPDEMORQ_SQLSERVER";
@@ -64,9 +65,12 @@ public sealed class InventoryMdpSyncService : ITransient
         var historyFrom = asOf.Date.AddMonths(-months);
         var batchId = $"S5_INV_XFORM_{asOf:yyyyMMddHHmmss}";
 
-        var locked = await TryAcquireLockAsync();
-        if (!locked)
+        // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
+        await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
+        if (!guard.Acquired)
         {
+            _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-std 本轮跳过 batch={Batch} reason={Reason}",
+                batchId, guard.BusyReason);
             return new InventorySyncResult
             {
                 BatchId = batchId,
@@ -79,25 +83,18 @@ public sealed class InventoryMdpSyncService : ITransient
             };
         }
 
-        try
-        {
-            var rows = await UpsertInvTransStdAsync(tenantId, batchId: null, asOf, historyFrom, sourceCode);
-            _logger.LogInformation("[InventoryMdpSync] transform-std done tenant={Tenant} rows={Rows}", tenantId, rows);
-            return new InventorySyncResult
-            {
-                BatchId = batchId,
-                TenantId = tenantId,
-                Domain = domain,
-                TransStdRows = rows,
-                AsOf = asOf,
-                HistoryFrom = historyFrom,
-                Message = "OK transform-std"
-            };
-        }
-        finally
+        var rows = await UpsertInvTransStdAsync(tenantId, batchId: null, asOf, historyFrom, sourceCode);
+        _logger.LogInformation("[InventoryMdpSync] transform-std done tenant={Tenant} rows={Rows}", tenantId, rows);
+        return new InventorySyncResult
         {
-            await ReleaseLockAsync();
-        }
+            BatchId = batchId,
+            TenantId = tenantId,
+            Domain = domain,
+            TransStdRows = rows,
+            AsOf = asOf,
+            HistoryFrom = historyFrom,
+            Message = "OK transform-std"
+        };
     }
 
     /// <summary>
@@ -126,6 +123,23 @@ public sealed class InventoryMdpSyncService : ITransient
         var sourceTenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
         var asOf = DateTime.Now;
 
+        // 本入口同样写 mdp_std_inventory,必须与 bootstrap/reconcile/incremental 互斥(原实现漏取锁)
+        await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
+        if (!guard.Acquired)
+        {
+            _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-inventory-std 本轮跳过 batch={Batch} reason={Reason}",
+                stgBatchId, guard.BusyReason);
+            return new InventorySyncResult
+            {
+                BatchId = stgBatchId,
+                TenantId = sourceTenantId,
+                Domain = domain,
+                AsOf = asOf,
+                Skipped = true,
+                Message = "lock busy"
+            };
+        }
+
         var stgRows = await _db.Ado.GetIntAsync(
             """
             SELECT COUNT(1) FROM mdp_stg_inventory
@@ -179,10 +193,11 @@ public sealed class InventoryMdpSyncService : ITransient
         var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
         var historyFrom = asOf.Date.AddMonths(-months);
 
-        var locked = await TryAcquireLockAsync();
-        if (!locked)
+        // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
+        await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
+        if (!guard.Acquired)
         {
-            _logger.LogWarning("[InventoryMdpSync] 跨实例锁占用,本轮跳过 batch={Batch}", batchId);
+            _logger.LogWarning("[InventoryMdpSync] 跨实例锁占用,本轮跳过 batch={Batch} reason={Reason}", batchId, guard.BusyReason);
             return new InventorySyncResult
             {
                 BatchId = batchId,
@@ -196,114 +211,107 @@ public sealed class InventoryMdpSyncService : ITransient
             };
         }
 
-        try
-        {
-            var upperLoc = await CaptureUpperBoundAsync(sourceCode, "LocationDetail", "UpdateTime", cancellationToken);
-            var upperTrans = await CaptureUpperBoundAsync(sourceCode, "InvTransHist", "CreateTime", cancellationToken);
+        var upperLoc = await CaptureUpperBoundAsync(sourceCode, "LocationDetail", "UpdateTime", cancellationToken);
+        var upperTrans = await CaptureUpperBoundAsync(sourceCode, "InvTransHist", "CreateTime", cancellationToken);
 
-            MdpPullResult locPull;
-            if (bootstrap || reconcile)
-            {
-                var locBatch = $"{batchId}_LOC";
-                // NULL UpdateTime 段与非 NULL 段共用同一 batchId,保证 Replace 不漏 NULL 行
-                var nullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
-                    cursorColumn: "UpdateTime", nullPhase: true, bootstrapFull: true);
-                nullCtx.CursorValue = null;
-                nullCtx.TieBreakerValue = null;
-                await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, nullCtx, cancellationToken, maxPages: 50);
-
-                var fullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
-                    cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: true);
-                fullCtx.CursorValue = null;
-                fullCtx.TieBreakerValue = null;
-                fullCtx.BootstrapFrom = null; // LocationDetail 首刷不截时间窗
-                locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, fullCtx, cancellationToken, maxPages: 200);
-            }
-            else
+        MdpPullResult locPull;
+        if (bootstrap || reconcile)
+        {
+            var locBatch = $"{batchId}_LOC";
+            // NULL UpdateTime 段与非 NULL 段共用同一 batchId,保证 Replace 不漏 NULL 行
+            var nullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
+                cursorColumn: "UpdateTime", nullPhase: true, bootstrapFull: true);
+            nullCtx.CursorValue = null;
+            nullCtx.TieBreakerValue = null;
+            await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, nullCtx, cancellationToken, maxPages: 50);
+
+            var fullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
+                cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: true);
+            fullCtx.CursorValue = null;
+            fullCtx.TieBreakerValue = null;
+            fullCtx.BootstrapFrom = null; // LocationDetail 首刷不截时间窗
+            locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, fullCtx, cancellationToken, maxPages: 200);
+        }
+        else
+        {
+            var incrCtx = BuildKeysetCtx(tenantId, $"{batchId}_LOC", asOf, historyFrom, upperLoc,
+                cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: false);
+            // 重叠窗口:从上次游标时间向前回退 LocationOverlapMinutes
+            if (!string.IsNullOrWhiteSpace(incrCtx.CursorValue)
+                && DateTime.TryParse(incrCtx.CursorValue, out var lastDt))
             {
-                var incrCtx = BuildKeysetCtx(tenantId, $"{batchId}_LOC", asOf, historyFrom, upperLoc,
-                    cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: false);
-                // 重叠窗口:从上次游标时间向前回退 LocationOverlapMinutes
-                if (!string.IsNullOrWhiteSpace(incrCtx.CursorValue)
-                    && DateTime.TryParse(incrCtx.CursorValue, out var lastDt))
-                {
-                    var overlap = Math.Max(0, _opt.LocationOverlapMinutes);
-                    incrCtx.CursorValue = lastDt.AddMinutes(-overlap)
-                        .ToString("yyyy-MM-dd HH:mm:ss.fff");
-                    incrCtx.TieBreakerValue = "0";
-                }
-                locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, incrCtx, cancellationToken, maxPages: 200);
+                var overlap = Math.Max(0, _opt.LocationOverlapMinutes);
+                incrCtx.CursorValue = lastDt.AddMinutes(-overlap)
+                    .ToString("yyyy-MM-dd HH:mm:ss.fff");
+                incrCtx.TieBreakerValue = "0";
             }
+            locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, incrCtx, cancellationToken, maxPages: 200);
+        }
 
-            // —— 标准层按租户物化:一次贴源,逐租户按各自库位范围投影 ——
-            // 贴源层是「源+domain」维度(归属 sourceTenantId),标准层是「租户」维度。
-            // 拉取游标持久化在 mdp_entity 上、跨租户共享,故绝不能为每个租户各拉一次。
-            var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
-            if (targetTenants.Count == 0)
-                _logger.LogWarning(
-                    "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,标准层本轮不写入", domain);
+        // —— 标准层按租户物化:一次贴源,逐租户按各自库位范围投影 ——
+        // 贴源层是「源+domain」维度(归属 sourceTenantId),标准层是「租户」维度。
+        // 拉取游标持久化在 mdp_entity 上、跨租户共享,故绝不能为每个租户各拉一次。
+        var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
+        if (targetTenants.Count == 0)
+            _logger.LogWarning(
+                "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,标准层本轮不写入", domain);
 
-            var inventoryStdRows = 0;
-            foreach (var targetTenantId in targetTenants)
+        var inventoryStdRows = 0;
+        foreach (var targetTenantId in targetTenants)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            int rows;
+            if (bootstrap || reconcile)
             {
-                cancellationToken.ThrowIfCancellationRequested();
-                int rows;
-                if (bootstrap || reconcile)
-                {
-                    rows = await MdpStdFullReplace.ReplaceAsync(
-                        _db,
-                        "mdp_std_inventory",
-                        targetTenantId,
-                        "source_system='DOPDEMORQ_SQLSERVER'",
-                        () => InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
-                        cancellationToken);
-                }
-                else
-                {
-                    rows = await InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
-                }
-                inventoryStdRows += rows;
-                _logger.LogInformation(
-                    "[InventoryMdpSync] std materialized tenant={Tenant} domain={Domain} rows={Rows}",
-                    targetTenantId, domain, rows);
+                rows = await MdpStdFullReplace.ReplaceAsync(
+                    _db,
+                    "mdp_std_inventory",
+                    targetTenantId,
+                    "source_system='DOPDEMORQ_SQLSERVER'",
+                    () => InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
+                    cancellationToken);
             }
-
-            var transCtx = BuildKeysetCtx(tenantId, $"{batchId}_TRN", asOf, historyFrom, upperTrans,
-                cursorColumn: "CreateTime", nullPhase: false, bootstrapFull: bootstrap || reconcile);
-            if (bootstrap || reconcile)
+            else
             {
-                transCtx.CursorValue = null;
-                transCtx.TieBreakerValue = null;
-                transCtx.BootstrapFrom = historyFrom;
+                rows = await InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
             }
-            var transPull = await _pullDispatcher.PullAllByEntityCodeAsync(TransEntity, transCtx, cancellationToken, maxPages: 500);
-            var transStdRows = await UpsertInvTransStdAsync(tenantId, $"{batchId}_TRN", asOf, historyFrom, sourceCode);
-
+            inventoryStdRows += rows;
             _logger.LogInformation(
-                "[InventoryMdpSync] done batch={Batch} boot={Boot} recon={Recon} locPulled={LocP} invStd={Inv} trnPulled={TrnP} trnStd={Trn}",
-                batchId, bootstrap, reconcile, locPull.RowsPulled, inventoryStdRows, transPull.RowsPulled, transStdRows);
-
-            return new InventorySyncResult
-            {
-                BatchId = batchId,
-                TenantId = tenantId,
-                Domain = domain,
-                Bootstrap = bootstrap,
-                LocationPulled = locPull.RowsPulled,
-                LocationWritten = locPull.RowsWritten,
-                InventoryStdRows = inventoryStdRows,
-                TransPulled = transPull.RowsPulled,
-                TransWritten = transPull.RowsWritten,
-                TransStdRows = transStdRows,
-                AsOf = asOf,
-                HistoryFrom = historyFrom,
-                Message = "OK"
-            };
+                "[InventoryMdpSync] std materialized tenant={Tenant} domain={Domain} rows={Rows}",
+                targetTenantId, domain, rows);
         }
-        finally
+
+        var transCtx = BuildKeysetCtx(tenantId, $"{batchId}_TRN", asOf, historyFrom, upperTrans,
+            cursorColumn: "CreateTime", nullPhase: false, bootstrapFull: bootstrap || reconcile);
+        if (bootstrap || reconcile)
         {
-            await ReleaseLockAsync();
+            transCtx.CursorValue = null;
+            transCtx.TieBreakerValue = null;
+            transCtx.BootstrapFrom = historyFrom;
         }
+        var transPull = await _pullDispatcher.PullAllByEntityCodeAsync(TransEntity, transCtx, cancellationToken, maxPages: 500);
+        var transStdRows = await UpsertInvTransStdAsync(tenantId, $"{batchId}_TRN", asOf, historyFrom, sourceCode);
+
+        _logger.LogInformation(
+            "[InventoryMdpSync] done batch={Batch} boot={Boot} recon={Recon} locPulled={LocP} invStd={Inv} trnPulled={TrnP} trnStd={Trn}",
+            batchId, bootstrap, reconcile, locPull.RowsPulled, inventoryStdRows, transPull.RowsPulled, transStdRows);
+
+        return new InventorySyncResult
+        {
+            BatchId = batchId,
+            TenantId = tenantId,
+            Domain = domain,
+            Bootstrap = bootstrap,
+            LocationPulled = locPull.RowsPulled,
+            LocationWritten = locPull.RowsWritten,
+            InventoryStdRows = inventoryStdRows,
+            TransPulled = transPull.RowsPulled,
+            TransWritten = transPull.RowsWritten,
+            TransStdRows = transStdRows,
+            AsOf = asOf,
+            HistoryFrom = historyFrom,
+            Message = "OK"
+        };
     }
 
     private MdpPullContext BuildKeysetCtx(
@@ -592,18 +600,9 @@ public sealed class InventoryMdpSyncService : ITransient
         return await _db.Ado.ExecuteCommandAsync(sql, ps);
     }
 
-    private async Task<bool> TryAcquireLockAsync()
-    {
-        var ok = await _db.Ado.GetIntAsync(
-            "SELECT GET_LOCK(@k, 0)",
-            new List<SugarParameter> { new("@k", LockKey) });
-        return ok == 1;
-    }
-
-    private Task ReleaseLockAsync()
-        => _db.Ado.ExecuteCommandAsync(
-            "SELECT RELEASE_LOCK(@k)",
-            new List<SugarParameter> { new("@k", LockKey) });
+    // 取/放锁已迁至 InventoryInboundLockGuard:
+    // 原实现在共享 _db(IsAutoCloseConnection=true)上跑 GET_LOCK/RELEASE_LOCK,
+    // 命令执行完连接即回池并被驱动 reset,MySQL 当场释放咨询锁 → 跨实例互斥失效。
 
     private sealed class UpperRow
     {