using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Entity; using SqlSugar; using Yitter.IdGenerator; namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Idempotency; /// /// 生产 IQC 过账头幂等存储:依赖 `ado_iqc_inventory_posting` 的 DB UNIQUE `uk_ado_iqc_posting_fbillno` /// (tenant_id, domain_code, fbillno) 原子占位——**INSERT 直上,撞唯一约束(1062)时回读既有记录**,不做先查后插。 /// /// ⚠️ Phase 4B(未在本轮实库验证):目标库 = aidopdev(ConfigId 1300000000001);DB UNIQUE 实际拒绝与真实并发行为待补。 /// public sealed class SqlSugarIqcPostingStore : IIqcPostingStore, ITransient { private readonly ISqlSugarClient _db; public SqlSugarIqcPostingStore(ISqlSugarClient db) { _db = db; } public async Task TryInsertAsync(AdoIqcInventoryPosting posting) { if (posting == null) throw new ArgumentNullException(nameof(posting)); if (posting.Id == 0) posting.Id = YitIdHelper.NextId(); if (posting.CreateTime == default) posting.CreateTime = DateTime.Now; if (posting.UpdateTime == null) posting.UpdateTime = posting.CreateTime; // 认领即起租(lease 心跳基准) try { await _db.Insertable(posting).ExecuteCommandAsync(); return new PostingInsertResult { Inserted = true }; } catch (Exception ex) when (IsDuplicateKey(ex)) { var existing = await GetAsync(posting.TenantId, posting.DomainCode, posting.FbillNo); return new PostingInsertResult { Inserted = false, Existing = existing }; } } public async Task GetAsync(long tenantId, string domainCode, string fbillNo) { var domain = domainCode ?? string.Empty; return await _db.Queryable() .Where(x => x.TenantId == tenantId && x.DomainCode == domain && x.FbillNo == fbillNo) .FirstAsync(); } public async Task UpdateStatusAsync(AdoIqcInventoryPosting posting, string status) { if (posting == null) throw new ArgumentNullException(nameof(posting)); await _db.Ado.ExecuteCommandAsync( "UPDATE ado_iqc_inventory_posting SET posting_status=@st, update_time=@now WHERE tenant_id=@t AND domain_code=@d AND fbillno=@fb", new SugarParameter("@st", status), new SugarParameter("@now", DateTime.Now), new SugarParameter("@t", posting.TenantId), new SugarParameter("@d", posting.DomainCode ?? ""), new SugarParameter("@fb", posting.FbillNo)); posting.PostingStatus = status; } public async Task TryRecoverStaleAsync(long tenantId, string domainCode, string fbillNo, DateTime staleBefore, DateTime now) { // 单条件原子更新:仅 PROCESSING 且心跳过期者被抢占(rows=1);并发多路仅一路命中,无先查后改窗口。 var rows = await _db.Ado.ExecuteCommandAsync( "UPDATE ado_iqc_inventory_posting SET update_time=@now " + "WHERE tenant_id=@t AND domain_code=@d AND fbillno=@fb AND posting_status='PROCESSING' AND update_time < @stale", new SugarParameter("@now", now), new SugarParameter("@t", tenantId), new SugarParameter("@d", domainCode ?? ""), new SugarParameter("@fb", fbillNo), new SugarParameter("@stale", staleBefore)); return rows == 1; } /// 沿异常链识别 MySQL 唯一键冲突(1062 / Duplicate entry / 目标唯一索引名)。 private static bool IsDuplicateKey(Exception ex) { for (var e = ex; e != null; e = e.InnerException) { var m = e.Message; if (!string.IsNullOrEmpty(m) && (m.Contains("Duplicate entry") || m.Contains("1062") || m.Contains("uk_ado_iqc_posting_fbillno"))) return true; } return false; } }