Jelajahi Sumber

fix(s0): enforce tenant isolation for locations and shelves

移除库位/货架 Controller 的 [AllowAnonymous],改走全局认证;新增 AdoS0TenantScope
可信租户解析(JWT claim,无默认回落,无 token/超管未选租户即拒)。库位与货架所有
读写显式限定当前租户:列表/详情/options 加 tenant 过滤,Get/Update/Delete 按 id+tenant
定位(他租户→NotFound),Create 显式赋 tenant + 落库断言,FULL Replace 删货架条件含
tenant_id,货架父库位与唯一性校验含 tenant。ReferenceChecker 货架引用检查按
tenant+domain+location 限定。仅修库位与货架,其余 A 类 Controller 另批。

bump version server 1.0.294
YY968XX 1 Minggu lalu
induk
melakukan
23da3f6c0f

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.293</AssemblyVersion>
-    <FileVersion>1.0.293</FileVersion>
-    <Version>1.0.293</Version>
+    <AssemblyVersion>1.0.294</AssemblyVersion>
+    <FileVersion>1.0.294</FileVersion>
+    <Version>1.0.294</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 34 - 59
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0LocationShelvesController.cs

@@ -1,42 +1,40 @@
 using Admin.NET.Plugin.AiDOP.Dto.S0.Warehouse;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
-using Microsoft.Extensions.Logging;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 
 /// <summary>
-/// S0 货架主数据(LocationShelfMaster 语义)
+/// S0 货架主数据(LocationShelfMaster 语义)。独立货架页 CRUD。
+/// 多租户隔离:所有读写显式限定当前请求租户;父库位存在性按 tenant+domain+location 校验;不依赖全局 AOP。
 /// </summary>
 [ApiController]
 [Route("api/s0/warehouse/location-shelves")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0LocationShelvesController : ControllerBase
 {
     private readonly SqlSugarRepository<AdoS0LocationShelfMaster> _rep;
     private readonly SqlSugarRepository<AdoS0LocationMaster> _locRep;
     private readonly AdoS0ReferenceChecker _refChecker;
-    private readonly ILogger<AdoS0LocationShelvesController> _logger;
 
     public AdoS0LocationShelvesController(
         SqlSugarRepository<AdoS0LocationShelfMaster> rep,
         SqlSugarRepository<AdoS0LocationMaster> locRep,
-        AdoS0ReferenceChecker refChecker,
-        ILogger<AdoS0LocationShelvesController> logger)
+        AdoS0ReferenceChecker refChecker)
     {
         _rep = rep;
         _locRep = locRep;
         _refChecker = refChecker;
-        _logger = logger;
     }
 
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0LocationShelfQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
 
         var query = _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId)
             .WhereIF(q.CompanyRefId.HasValue, x => x.CompanyRefId == q.CompanyRefId!.Value)
             .WhereIF(q.FactoryRefId.HasValue, x => x.FactoryRefId == q.FactoryRefId!.Value)
             .WhereIF(!string.IsNullOrWhiteSpace(q.DomainCode), x => x.DomainCode == q.DomainCode)
@@ -52,7 +50,7 @@ public class AdoS0LocationShelvesController : ControllerBase
             .Take(q.PageSize)
             .ToListAsync();
 
-        await ApplyLocationDescrAsync(list);
+        await ApplyLocationDescrAsync(list, tenantId);
 
         return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
     }
@@ -60,32 +58,31 @@ public class AdoS0LocationShelvesController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetAsync(long id)
     {
-        var item = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (item == null) return NotFound();
-        await ApplyLocationDescrAsync(new List<AdoS0LocationShelfMaster> { item });
+        await ApplyLocationDescrAsync(new List<AdoS0LocationShelfMaster> { item }, tenantId);
         return Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0LocationShelfUpsertDto dto)
     {
-        if (await _rep.IsAnyAsync(x => x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location && x.InvShelf == dto.InvShelf))
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        // 唯一性:当前租户 + domain + location + invShelf
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.DomainCode == dto.DomainCode && x.Location == dto.Location && x.InvShelf == dto.InvShelf))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "货架编码已存在");
 
-        // B2:Create 一律严格作用域校验,禁止降级(参 AdoS0MfgRoutingOpDetailsController)。
-        var locResult = await _refChecker.LocationExistsInScopeAsync(dto.Location, dto.CompanyRefId, dto.FactoryRefId);
-        switch (locResult)
-        {
-            case MaterialScopeCheck.NotFound:
-                return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
-                    $"库位编码 '{dto.Location}' 不存在于库位主数据");
-            case MaterialScopeCheck.ScopeMiss:
-                return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.InvalidReferenceScope,
-                    $"库位编码 '{dto.Location}' 不属于当前公司/工厂 (CompanyRefId={dto.CompanyRefId}, FactoryRefId={dto.FactoryRefId})");
-        }
+        // 父库位存在性:必须为当前租户下同 domain+location 的库位(不得为他租户库位建货架)
+        if (!await _refChecker.LocationExistsInTenantAsync(tenantId, dto.DomainCode, dto.Location))
+            return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
+                $"库位编码 '{dto.Location}' 不存在于当前租户库位主数据");
 
         var entity = new AdoS0LocationShelfMaster
         {
+            TenantId = tenantId,
             CompanyRefId = dto.CompanyRefId,
             FactoryRefId = dto.FactoryRefId,
             DomainCode = dto.DomainCode ?? string.Empty,
@@ -104,46 +101,19 @@ public class AdoS0LocationShelvesController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0LocationShelfUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
 
-        if (await _rep.IsAnyAsync(x => x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location && x.InvShelf == dto.InvShelf))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.DomainCode == dto.DomainCode && x.Location == dto.Location && x.InvShelf == dto.InvShelf))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "货架编码已存在");
 
-        // B2 + 历史兼容降级(D-03 严格条件,参 AdoS0MfgRoutingOpDetailsController)
-        var locResult = await _refChecker.LocationExistsInScopeAsync(dto.Location, dto.CompanyRefId, dto.FactoryRefId);
-        if (locResult == MaterialScopeCheck.NotFound)
+        if (!await _refChecker.LocationExistsInTenantAsync(tenantId, dto.DomainCode, dto.Location))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
-                $"库位编码 '{dto.Location}' 不存在于库位主数据");
-
-        if (locResult == MaterialScopeCheck.ScopeMiss)
-        {
-            // 降级条件(全部满足才允许):
-            //   1. 是 Update(已满足)
-            //   2. DB 原记录 scope 为 0/0
-            //   3. 本次未修改 Location 引用值
-            var originScopeEmpty = entity.CompanyRefId == 0 && entity.FactoryRefId == 0;
-            var locationUnchanged = string.Equals(entity.Location, dto.Location?.Trim(), StringComparison.Ordinal);
-
-            if (originScopeEmpty && locationUnchanged)
-            {
-                _logger.LogWarning(
-                    "[B2 Downgrade] Table={TableName} PrimaryKey={PrimaryKey} ReferenceField={ReferenceField} " +
-                    "OldValue={OldValue} NewValue={NewValue} CompanyRefId={CompanyRefId} FactoryRefId={FactoryRefId} " +
-                    "DowngradeReason={DowngradeReason}",
-                    "LocationShelfMaster", entity.Id, "Location",
-                    entity.Location, dto.Location,
-                    dto.CompanyRefId, dto.FactoryRefId,
-                    "LegacyRecord_OriginScopeZero_LocationUnchanged");
-                // 放行,继续执行写入
-            }
-            else
-            {
-                return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.InvalidReferenceScope,
-                    $"库位编码 '{dto.Location}' 不属于当前公司/工厂 (CompanyRefId={dto.CompanyRefId}, FactoryRefId={dto.FactoryRefId})");
-            }
-        }
+                $"库位编码 '{dto.Location}' 不存在于当前租户库位主数据");
 
+        // entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant
         entity.CompanyRefId = dto.CompanyRefId;
         entity.FactoryRefId = dto.FactoryRefId;
         entity.DomainCode = dto.DomainCode ?? string.Empty;
@@ -161,19 +131,24 @@ public class AdoS0LocationShelvesController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var item = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (item == null) return NotFound();
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });
     }
 
-    private async Task ApplyLocationDescrAsync(List<AdoS0LocationShelfMaster> shelves)
+    /// <summary>
+    /// 补显库位说明 + 「库位:货架」拼接。关联 LocationMaster 时限定当前租户,避免同编码库位跨租户补错描述。
+    /// </summary>
+    private async Task ApplyLocationDescrAsync(List<AdoS0LocationShelfMaster> shelves, long tenantId)
     {
         if (shelves.Count == 0) return;
 
         var domainCodes = shelves.Select(s => s.DomainCode).Distinct().ToList();
         var locations = await _locRep.AsQueryable()
-            .Where(l => domainCodes.Contains(l.DomainCode))
+            .Where(l => l.TenantId == tenantId && domainCodes.Contains(l.DomainCode))
             .ToListAsync();
 
         foreach (var shelf in shelves)

+ 48 - 22
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0LocationsController.cs

@@ -5,11 +5,11 @@ using Admin.NET.Plugin.AiDOP.Infrastructure;
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 
 /// <summary>
-/// S0 库位主数据(LocationMaster 语义)+ 货架明细(LocationShelfMaster)主从保存
+/// S0 库位主数据(LocationMaster 语义)+ 货架明细(LocationShelfMaster)主从保存。
+/// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP。
 /// </summary>
 [ApiController]
 [Route("api/s0/warehouse/locations")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0LocationsController : ControllerBase
 {
@@ -32,9 +32,11 @@ public class AdoS0LocationsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0LocationQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
 
         var query = _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId)
             .WhereIF(q.CompanyRefId.HasValue, x => x.CompanyRefId == q.CompanyRefId!.Value)
             .WhereIF(q.FactoryRefId.HasValue, x => x.FactoryRefId == q.FactoryRefId!.Value)
             .WhereIF(!string.IsNullOrWhiteSpace(q.DomainCode), x => x.DomainCode == q.DomainCode)
@@ -55,15 +57,17 @@ public class AdoS0LocationsController : ControllerBase
     }
 
     /// <summary>
-    /// 库位详情(含货架明细,供编辑回显)。货架按关联口径 tenant_id + domain_code + location 拉取
+    /// 库位详情(含货架明细,供编辑回显)。按 id + 当前租户定位;他租户记录统一返回 NotFound(不泄露存在性)
     /// </summary>
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetAsync(long id)
     {
-        var item = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (item == null) return NotFound();
 
-        var shelves = await LoadShelvesAsync(item);
+        var shelves = await LoadShelvesAsync(item, tenantId);
         var detail = new AdoS0LocationDetailDto
         {
             Id = item.Id,
@@ -94,10 +98,12 @@ public class AdoS0LocationsController : ControllerBase
         [FromQuery] bool? isActive,
         [FromQuery] int? limit)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var enabledFilter = isActive ?? true;
         var take = Math.Clamp(limit ?? 200, 1, 500);
 
         var list = await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId)
             .WhereIF(companyRefId.HasValue, x => x.CompanyRefId == companyRefId!.Value)
             .WhereIF(factoryRefId.HasValue, x => x.FactoryRefId == factoryRefId!.Value)
             .WhereIF(!string.IsNullOrWhiteSpace(domainCode), x => x.DomainCode == domainCode)
@@ -121,20 +127,23 @@ public class AdoS0LocationsController : ControllerBase
     }
 
     /// <summary>
-    /// 新增库位(含货架明细,主从同事务保存)。
+    /// 新增库位(含货架明细,主从同事务保存)。tenant_id 由服务端按当前租户赋值。
     /// </summary>
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0LocationUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
         var (shelfError, shelfItems) = ValidateShelves(dto.Shelves);
         if (shelfError != null) return shelfError;
 
-        if (await _rep.IsAnyAsync(x => x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "库位编码已存在");
 
         var now = DateTime.Now;
         var entity = new AdoS0LocationMaster
         {
+            TenantId = tenantId,
             CompanyRefId = dto.CompanyRefId,
             FactoryRefId = dto.FactoryRefId,
             DomainCode = dto.DomainCode ?? string.Empty,
@@ -157,7 +166,8 @@ public class AdoS0LocationsController : ControllerBase
 
             if (shelfItems.Count > 0)
             {
-                var shelfEntities = BuildShelfEntities(saved, shelfItems, dto.CreateUser, now);
+                var shelfEntities = BuildShelfEntities(saved, shelfItems, tenantId, dto.CreateUser, now);
+                AssertTenant(tenantId, saved, shelfEntities);
                 await _shelfRep.AsInsertable(shelfEntities).ExecuteCommandAsync();
             }
 
@@ -172,25 +182,28 @@ public class AdoS0LocationsController : ControllerBase
     }
 
     /// <summary>
-    /// 编辑库位(含货架明细 FULL Replace,主从同事务保存)。库位编码不可修改。
+    /// 编辑库位(含货架明细 FULL Replace,主从同事务保存)。按 id + 当前租户定位;库位编码不可修改;忽略前端传入的 tenant
     /// </summary>
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0LocationUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
         var (shelfError, shelfItems) = ValidateShelves(dto.Shelves);
         if (shelfError != null) return shelfError;
 
-        var entity = await _rep.GetByIdAsync(id);
+        var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
 
         // 库位编码不可修改:以库存原值为准,前端传值须一致
         if (!string.Equals(entity.Location, dto.Location?.Trim(), StringComparison.Ordinal))
             return AdoS0ApiErrors.InvalidRequest("库位编码不可修改");
 
-        if (await _rep.IsAnyAsync(x => x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "库位编码已存在");
 
         var now = DateTime.Now;
+        // entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant
         entity.CompanyRefId = dto.CompanyRefId;
         entity.FactoryRefId = dto.FactoryRefId;
         entity.DomainCode = dto.DomainCode ?? string.Empty;
@@ -210,14 +223,15 @@ public class AdoS0LocationsController : ControllerBase
 
             await _rep.AsUpdateable(entity).ExecuteCommandAsync();
 
-            // FULL Replace:删除本库位当前作用域(tenant + domain + location)下全部货架,再整体重插
+            // FULL Replace:显式限定 tenant + domain + location 删本库位全部货架,再整体重插(禁止仅按 domain+location)
             await _shelfRep.AsDeleteable()
-                .Where(x => x.DomainCode == entity.DomainCode && x.Location == entity.Location)
+                .Where(x => x.TenantId == tenantId && x.DomainCode == entity.DomainCode && x.Location == entity.Location)
                 .ExecuteCommandAsync();
 
             if (shelfItems.Count > 0)
             {
-                var shelfEntities = BuildShelfEntities(entity, shelfItems, dto.UpdateUser, now);
+                var shelfEntities = BuildShelfEntities(entity, shelfItems, tenantId, dto.UpdateUser, now);
+                AssertTenant(tenantId, entity, shelfEntities);
                 await _shelfRep.AsInsertable(shelfEntities).ExecuteCommandAsync();
             }
 
@@ -234,11 +248,13 @@ public class AdoS0LocationsController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var item = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (item == null) return NotFound();
 
-        // 保持既有删除契约:存在货架(或其它引用)时拦截,不做级联删除
-        var refInfo = await _refChecker.LocationReferencesAsync(item.Location);
+        // 引用检查限定 当前租户 + domain + location:不因他租户同编码货架而阻止/误删
+        var refInfo = await _refChecker.LocationReferencesAsync(tenantId, item.DomainCode, item.Location);
         if (refInfo is { } r)
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
                 $"存在 {r.Count} 条 {r.Table} 引用该库位,无法删除");
@@ -250,12 +266,12 @@ public class AdoS0LocationsController : ControllerBase
     // ==================== 私有:货架明细主从辅助 ====================
 
     /// <summary>
-    /// 按关联口径(tenant 自动过滤 + domain_code + location)拉取库位下货架明细。
+    /// 按关联口径(当前租户 + domain_code + location)拉取库位下货架明细。
     /// </summary>
-    private async Task<List<AdoS0LocationShelfInputDto>> LoadShelvesAsync(AdoS0LocationMaster master)
+    private async Task<List<AdoS0LocationShelfInputDto>> LoadShelvesAsync(AdoS0LocationMaster master, long tenantId)
     {
         return await _shelfRep.AsQueryable()
-            .Where(x => x.DomainCode == master.DomainCode && x.Location == master.Location)
+            .Where(x => x.TenantId == tenantId && x.DomainCode == master.DomainCode && x.Location == master.Location)
             .OrderBy(x => x.InvShelf)
             .Select(x => new AdoS0LocationShelfInputDto
             {
@@ -309,13 +325,14 @@ public class AdoS0LocationsController : ControllerBase
     }
 
     /// <summary>
-    /// 由库位主表统一赋值货架作用域字段(tenant 由 ITenantIdFilter 自动注入,此处不设)。
+    /// 由库位主表 + 当前租户统一赋值货架作用域字段(tenant_id 显式赋值,不依赖 AOP)。
     /// </summary>
     private static List<AdoS0LocationShelfMaster> BuildShelfEntities(
-        AdoS0LocationMaster master, List<AdoS0LocationShelfInputDto> items, string? actingUser, DateTime now)
+        AdoS0LocationMaster master, List<AdoS0LocationShelfInputDto> items, long tenantId, string? actingUser, DateTime now)
     {
         return items.Select(s => new AdoS0LocationShelfMaster
         {
+            TenantId = tenantId,
             CompanyRefId = master.CompanyRefId,
             FactoryRefId = master.FactoryRefId,
             DomainCode = master.DomainCode,
@@ -328,6 +345,15 @@ public class AdoS0LocationsController : ControllerBase
         }).ToList();
     }
 
+    /// <summary>
+    /// 落库前断言:主表与全部货架的 TenantId 均等于当前租户且非空非 0(W-01 NULL 防线)。
+    /// </summary>
+    private static void AssertTenant(long tenantId, AdoS0LocationMaster master, List<AdoS0LocationShelfMaster> shelves)
+    {
+        if (tenantId <= 0 || master.TenantId != tenantId || shelves.Any(s => s.TenantId != tenantId))
+            throw new InvalidOperationException("租户归属断言失败:主表/货架 TenantId 必须等于当前租户且非空。");
+    }
+
     /// <summary>
     /// 写入异常映射:唯一键冲突 → 清晰业务错误;其余 → 500(事务已回滚)。
     /// </summary>

+ 16 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0ReferenceChecker.cs

@@ -257,15 +257,27 @@ public sealed class AdoS0ReferenceChecker
     }
 
     /// <summary>
-    /// 库位(LocationMaster)删除前引用检查
-    /// 覆盖下游:货架 / 物料默认库位 / 产线 6 个 Location 变体
+    /// 租户内库位(LocationMaster)存在性检查:当前租户 + domain + location 命中即视为父库位存在
+    /// 供货架写入的父库位校验(不依赖全局 AOP,显式限定租户,杜绝为他租户库位建货架)
     /// </summary>
-    public async Task<(int Count, string Table)?> LocationReferencesAsync(string? location)
+    public async Task<bool> LocationExistsInTenantAsync(long tenantId, string? domainCode, string? location)
+    {
+        if (string.IsNullOrWhiteSpace(location)) return false;
+        return await _db.Queryable<AdoS0LocationMaster>()
+            .Where(x => x.TenantId == tenantId && x.DomainCode == domainCode && x.Location == location)
+            .AnyAsync();
+    }
+
+    /// <summary>
+    /// 库位(LocationMaster)删除前引用检查(租户隔离版)。
+    /// 覆盖下游:货架(显式限定 tenant+domain+location,不因他租户同码货架而阻止/误判)/ 物料默认库位 / 产线 6 个 Location 变体。
+    /// </summary>
+    public async Task<(int Count, string Table)?> LocationReferencesAsync(long tenantId, string? domainCode, string? location)
     {
         if (string.IsNullOrWhiteSpace(location)) return null;
 
         var shelf = await _db.Queryable<AdoS0LocationShelfMaster>()
-            .Where(x => x.Location == location)
+            .Where(x => x.TenantId == tenantId && x.DomainCode == domainCode && x.Location == location)
             .CountAsync();
         if (shelf > 0) return (shelf, "货架 (LocationShelfMaster)");
 

+ 48 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0TenantScope.cs

@@ -0,0 +1,48 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// S0 仓储基础资料的可信租户解析(写/读业务表统一取此值)。
+///
+/// 铁律(W-01/02/03 修复):
+///  - 租户只来自认证后的 JWT claim(UserManager.TenantId),前端 payload 的 tenantId 一律忽略;
+///  - 不使用任何固定默认租户兜底(区别于 QmsTenantScope.Current() 会回落 DefaultTenantId);
+///  - 无有效租户(无 token → App.User 空 → TenantId=0)→ 拒绝;
+///  - 超级管理员未显式选择目标租户(其 claim 即自身主租户 <see cref="MainTenantId"/>)→ 拒绝,
+///    必须在登录时选择目标租户(SysAuthService 支持超管登录传 tenantId 覆盖)。
+/// </summary>
+public static class AdoS0TenantScope
+{
+    /// <summary>主/系统租户(超管自身归属,== QmsTenantScope.DefaultTenantId)。仅用作"超管未选租户"的拒绝哨兵,绝不作为业务数据归属。</summary>
+    public const long MainTenantId = 1300000000001L;
+
+    /// <summary>
+    /// 解析当前请求的可信目标租户。成功返回 true 且 tenantId 为非空非 0 的有效租户;
+    /// 失败返回 false 且 error 为明确业务错误(调用方直接 return error)。
+    /// </summary>
+    public static bool TryResolveRequired(out long tenantId, out IActionResult? error)
+    {
+        tenantId = 0;
+        error = null;
+
+        var um = App.GetRequiredService<UserManager>();
+        var tid = um.TenantId; // 认证后的 JWT claim;无 token 时为 0
+
+        if (tid <= 0)
+        {
+            error = AdoS0ApiErrors.InvalidRequest("无法确定当前租户,请重新登录或选择目标租户");
+            return false;
+        }
+
+        // 超管的 claim 若等于自身主租户,视为"未选择目标租户"(不允许自动用超管自身/固定默认租户写业务数据)
+        if (um.SuperAdmin && tid == MainTenantId)
+        {
+            error = AdoS0ApiErrors.InvalidRequest("超级管理员操作前必须选择目标租户");
+            return false;
+        }
+
+        tenantId = tid;
+        return true;
+    }
+}