Просмотр исходного кода

fix(s0,s6): harden tenant isolation (server 1.0.312)

S0 BarCodeNbr / wms_rwzp: add tenant_id (NOT NULL) + tenant-aware unique index, strict AdoS0TenantScope on all CRUD, remove [AllowAnonymous].
S6 IPQC Flow/Disposition: strict ResolveTenantOrThrow (reject superadmin-unselected / no-token), tenant predicate on all reads/writes incl outbox and ClearFilter queries.
Runtime dual-tenant smoke PASS. DDL idempotent; both tables 0 rows.
YY968XX 2 месяцев назад
Родитель
Сommit
8826987ee3

+ 6 - 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.311</AssemblyVersion>
-    <FileVersion>1.0.311</FileVersion>
-    <Version>1.0.311</Version>
+    <AssemblyVersion>1.0.312</AssemblyVersion>
+    <FileVersion>1.0.312</FileVersion>
+    <Version>1.0.312</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -286,6 +286,9 @@
     <None Update="UpdateScripts\1.0.304.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.312.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\1.0.309.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 109 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.312.sql

@@ -0,0 +1,109 @@
+-- 1.0.312.sql  S0/S6 租户隔离收口:BarCodeNbr / wms_rwzp 租户化 DDL(幂等 + information_schema 守卫 + 冲突前置)。
+--   本批同时收口 S6 IPQC Flow/Disposition 服务的严格租户解析(纯代码,无 DDL)。
+--   两表阶段 2 运行时双租户验收已 PASS:无 Token 拒绝 / 超管未选拒绝 / 跨租户 CRUD/状态阻断 / 租户内唯一。
+--   两表均 0 行 → ADD tenant_id BIGINT NOT NULL(无 DEFAULT)+ 租户内业务唯一索引;不插/改/删任何业务数据、不触碰其它表。
+--   幂等:可重复执行;ADD COLUMN / ADD INDEX 均以 information_schema 守卫;冲突则跳过、绝不自动清理数据。
+-- ============================================================================
+
+-- ############################################################################
+-- # SECTION 1 — BarCodeNbr 条码规则主数据 租户化
+-- #   租户内唯一:uk_BarCodeNbr_tenant_domain_customer_type (tenant_id, Domain, Customer, Type)
+-- ############################################################################
+
+-- 一、补 tenant_id 列(不存在才加;空表故 NOT NULL 无默认值安全,最终态无 DEFAULT)
+SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BarCodeNbr' AND COLUMN_NAME = 'tenant_id');
+SET @sql := IF(@has_col = 0,
+    'ALTER TABLE BarCodeNbr ADD COLUMN tenant_id BIGINT NOT NULL COMMENT ''租户 ID''',
+    'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+-- 兜底:若某些服务器在 ADD NOT NULL 时隐式落了 DEFAULT,立即剥离,保证最终态无默认值。
+SET @has_default := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BarCodeNbr' AND COLUMN_NAME = 'tenant_id'
+      AND COLUMN_DEFAULT IS NOT NULL);
+SET @sql := IF(@has_default > 0, 'ALTER TABLE BarCodeNbr ALTER COLUMN tenant_id DROP DEFAULT', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 二、冲突前置检测(同租户内 (Domain,Customer,Type) 重复则跳过建索引,绝不自动清理数据)
+SET @dup := (SELECT COUNT(*) FROM (
+    SELECT tenant_id, `Domain`, `Customer`, `Type`
+    FROM BarCodeNbr
+    GROUP BY tenant_id, `Domain`, `Customer`, `Type`
+    HAVING COUNT(*) > 1) t);
+SELECT @dup AS barcodenbr_intratenant_dup;   -- 期望 0(空表);若非 0,下方 ADD 被守卫跳过
+
+-- 三、创建租户内唯一索引(不存在且无同租户冲突才建)
+SET @has_uk := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BarCodeNbr'
+      AND INDEX_NAME = 'uk_BarCodeNbr_tenant_domain_customer_type');
+SET @sql := IF(@has_uk = 0 AND @dup = 0,
+    'ALTER TABLE BarCodeNbr ADD UNIQUE INDEX uk_BarCodeNbr_tenant_domain_customer_type (tenant_id, `Domain`, `Customer`, `Type`)',
+    'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 四、执行后验证
+SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BarCodeNbr' AND COLUMN_NAME = 'tenant_id';
+-- 期望:tenant_id bigint / IS_NULLABLE=NO / COLUMN_DEFAULT=NULL(无默认值)。
+SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'BarCodeNbr' AND INDEX_NAME <> 'PRIMARY'
+ORDER BY INDEX_NAME, SEQ_IN_INDEX;
+-- 期望:含 uk_BarCodeNbr_tenant_domain_customer_type (tenant_id, Domain, Customer, Type)(唯一)。
+
+-- ############################################################################
+-- # SECTION 2 — wms_rwzp 物料状态任务指派 租户化
+-- #   业务唯一口径(wms_rwzp 无单一任务号列,取"同租户+同工厂域下同物料任务的移动指派"复合键):
+-- #     uk_wms_rwzp_tenant_org_task (tenant_id, company_ref_id, factory_ref_id, domain_code, rwlx, wlbm, rqpc, yskw, mdkw)
+-- ############################################################################
+
+-- 一、补 tenant_id 列(不存在才加;0 行表 → NOT NULL 无 DEFAULT 安全)
+SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wms_rwzp' AND COLUMN_NAME = 'tenant_id');
+SET @sql := IF(@has_col = 0,
+    'ALTER TABLE wms_rwzp ADD COLUMN tenant_id BIGINT NOT NULL AFTER rec_id',
+    'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+-- 若历史执行遗留了 DEFAULT(本脚本本身不加 DEFAULT,此步为防御性收口,确保最终态无默认值):
+SET @has_default := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wms_rwzp' AND COLUMN_NAME = 'tenant_id'
+      AND COLUMN_DEFAULT IS NOT NULL);
+SET @sql := IF(@has_default > 0, 'ALTER TABLE wms_rwzp ALTER COLUMN tenant_id DROP DEFAULT', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 二、冲突前置检测(同租户+业务键重复则跳过创建,绝不自动清理历史数据)
+SET @dup := (SELECT COUNT(*) FROM (
+    SELECT tenant_id, company_ref_id, factory_ref_id, domain_code, rwlx, wlbm, rqpc, yskw, mdkw
+    FROM wms_rwzp
+    GROUP BY tenant_id, company_ref_id, factory_ref_id, domain_code, rwlx, wlbm, rqpc, yskw, mdkw
+    HAVING COUNT(*) > 1) t);
+SELECT @dup AS wms_rwzp_intratenant_dup;   -- 期望 0(0 行表必为 0);若非 0,下方 ADD 被守卫跳过
+
+-- 三、创建租户内业务唯一索引(不存在且无同键冲突才建)
+SET @has_uk := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wms_rwzp'
+      AND INDEX_NAME = 'uk_wms_rwzp_tenant_org_task');
+SET @sql := IF(@has_uk = 0 AND @dup = 0,
+    'ALTER TABLE wms_rwzp ADD UNIQUE INDEX uk_wms_rwzp_tenant_org_task (tenant_id, company_ref_id, factory_ref_id, domain_code, rwlx, wlbm, rqpc, yskw, mdkw)',
+    'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 四、执行后验证
+SELECT COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, DATA_TYPE
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wms_rwzp' AND COLUMN_NAME = 'tenant_id';
+-- 期望:tenant_id / IS_NULLABLE=NO / COLUMN_DEFAULT=NULL / DATA_TYPE=bigint。
+SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wms_rwzp' AND INDEX_NAME <> 'PRIMARY'
+ORDER BY INDEX_NAME, SEQ_IN_INDEX;
+-- 期望:uk_wms_rwzp_tenant_org_task (tenant_id, company_ref_id, factory_ref_id, domain_code, rwlx, wlbm, rqpc, yskw, mdkw)(NON_UNIQUE=0)。
+
+-- ============================================================================
+-- 回滚 SQL(空表期可安全执行):
+--   ALTER TABLE BarCodeNbr DROP INDEX uk_BarCodeNbr_tenant_domain_customer_type;
+--   ALTER TABLE BarCodeNbr DROP COLUMN tenant_id;
+--   ALTER TABLE wms_rwzp   DROP INDEX uk_wms_rwzp_tenant_org_task;
+--   ALTER TABLE wms_rwzp   DROP COLUMN tenant_id;
+-- ============================================================================

+ 29 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0BarcodeRulesController.cs

@@ -6,11 +6,11 @@ using Admin.NET.Plugin.AiDOP.Infrastructure;
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 
 /// <summary>
-/// S0 条码规则主数据(BarCodeNbr 语义)
+/// S0 条码规则主数据(BarCodeNbr 语义)。
+/// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP。
 /// </summary>
 [ApiController]
 [Route("api/s0/warehouse/barcode-rules")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0BarcodeRulesController : ControllerBase
 {
@@ -28,9 +28,11 @@ public class AdoS0BarcodeRulesController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0BarcodeRuleQueryDto 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)
@@ -46,7 +48,7 @@ public class AdoS0BarcodeRulesController : ControllerBase
             .Take(q.PageSize)
             .ToListAsync();
 
-        await ApplySuppNameAsync(list);
+        await ApplySuppNameAsync(list, tenantId);
 
         return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
     }
@@ -54,16 +56,25 @@ public class AdoS0BarcodeRulesController : 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 ApplySuppNameAsync(new List<AdoS0BarCodeNbr> { item });
+        await ApplySuppNameAsync(new List<AdoS0BarCodeNbr> { item }, tenantId);
         return Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0BarcodeRuleUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        // 唯一口径对齐数据库 uk_BarCodeNbr_tenant_domain_customer_type (tenant_id, Domain, Customer, Type)
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.DomainCode == (dto.DomainCode ?? string.Empty)
+                && x.Customer == dto.Customer && x.Type == dto.Type))
+            return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.BarcodeRuleCodeExists, "条码规则已存在");
+
         var entity = MapToEntity(dto);
+        entity.TenantId = tenantId;
         entity.CreateTime = DateTime.Now;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
         return Ok(entity);
@@ -72,9 +83,16 @@ public class AdoS0BarcodeRulesController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0BarcodeRuleUpsertDto 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.TenantId == tenantId && x.Id != id && x.DomainCode == (dto.DomainCode ?? string.Empty)
+                && x.Customer == dto.Customer && x.Type == dto.Type))
+            return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.BarcodeRuleCodeExists, "条码规则已存在");
+
+        // entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant
         entity.CompanyRefId = dto.CompanyRefId;
         entity.FactoryRefId = dto.FactoryRefId;
         entity.DomainCode = dto.DomainCode ?? string.Empty;
@@ -95,7 +113,8 @@ public class AdoS0BarcodeRulesController : 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 = "删除成功" });
@@ -136,7 +155,7 @@ public class AdoS0BarcodeRulesController : ControllerBase
         e.FirmString12 = dto.FirmString12; e.FirmStringLen12 = dto.FirmStringLen12; e.FirmString12Note = dto.FirmString12Note;
     }
 
-    private async Task ApplySuppNameAsync(List<AdoS0BarCodeNbr> rules)
+    private async Task ApplySuppNameAsync(List<AdoS0BarCodeNbr> rules, long tenantId)
     {
         if (rules.Count == 0) return;
 
@@ -148,8 +167,9 @@ public class AdoS0BarcodeRulesController : ControllerBase
 
         if (customerCodes.Count == 0) return;
 
+        // 供应商关联限定当前租户,避免同编码供应商跨租户补错简称
         var supps = await _suppRep.AsQueryable()
-            .Where(s => customerCodes.Contains(s.Supp))
+            .Where(s => s.TenantId == tenantId && customerCodes.Contains(s.Supp))
             .ToListAsync();
 
         foreach (var rule in rules)

+ 49 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0TaskAssignmentsController.cs

@@ -5,11 +5,11 @@ using Admin.NET.Plugin.AiDOP.Infrastructure;
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 
 /// <summary>
-/// S0 物料状态任务指派(wms_rwzp 语义)
+/// S0 物料状态任务指派(wms_rwzp 语义)。
+/// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP 兜底。
 /// </summary>
 [ApiController]
 [Route("api/s0/warehouse/task-assignments")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0TaskAssignmentsController : ControllerBase
 {
@@ -23,9 +23,11 @@ public class AdoS0TaskAssignmentsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0TaskAssignmentQueryDto 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)
@@ -48,15 +50,26 @@ public class AdoS0TaskAssignmentsController : 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();
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0TaskAssignmentUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        if (string.IsNullOrWhiteSpace(dto.DomainCode))
+            return AdoS0ApiErrors.InvalidRequest("请填写域编码");
+
+        // 唯一口径对齐数据库 uk_wms_rwzp_tenant_org_task
+        // (tenant_id, company_ref_id, factory_ref_id, domain_code, rwlx, wlbm, rqpc, yskw, mdkw)
+        if (await IsDuplicateAsync(tenantId, dto, null))
+            return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "相同工厂域下该物料任务指派已存在");
+
         var entity = new AdoS0TaskAssignment
         {
+            TenantId = tenantId,
             CompanyRefId = dto.CompanyRefId,
             FactoryRefId = dto.FactoryRefId,
             DomainCode = dto.DomainCode ?? string.Empty,
@@ -83,9 +96,17 @@ public class AdoS0TaskAssignmentsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0TaskAssignmentUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        if (string.IsNullOrWhiteSpace(dto.DomainCode))
+            return AdoS0ApiErrors.InvalidRequest("请填写域编码");
+
+        var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
 
+        if (await IsDuplicateAsync(tenantId, dto, id))
+            return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "相同工厂域下该物料任务指派已存在");
+
+        // entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant
         entity.CompanyRefId = dto.CompanyRefId;
         entity.FactoryRefId = dto.FactoryRefId;
         entity.DomainCode = dto.DomainCode ?? string.Empty;
@@ -111,9 +132,32 @@ public class AdoS0TaskAssignmentsController : 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 = "删除成功" });
     }
+
+    /// <summary>
+    /// 租户内业务唯一校验:对齐 uk_wms_rwzp_tenant_org_task
+    /// (tenant_id, company_ref_id, factory_ref_id, domain_code, rwlx, wlbm, rqpc, yskw, mdkw)。
+    /// excludeId 非空时排除自身(更新场景)。
+    /// </summary>
+    private async Task<bool> IsDuplicateAsync(long tenantId, AdoS0TaskAssignmentUpsertDto dto, long? excludeId)
+    {
+        var domainCode = dto.DomainCode ?? string.Empty;
+        return await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId
+                && x.CompanyRefId == dto.CompanyRefId
+                && x.FactoryRefId == dto.FactoryRefId
+                && x.DomainCode == domainCode
+                && x.Rwlx == dto.Rwlx
+                && x.Wlbm == dto.Wlbm
+                && x.Rqpc == dto.Rqpc
+                && x.Yskw == dto.Yskw
+                && x.Mdkw == dto.Mdkw)
+            .WhereIF(excludeId.HasValue, x => x.Id != excludeId!.Value)
+            .AnyAsync();
+    }
 }

+ 9 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0BarCodeNbr.cs

@@ -2,13 +2,15 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 
 /// <summary>
 /// 条码规则主数据(复刻 BarCodeNbr)。
-/// DB 存在新旧两套列:业务真实数据落在旧 PascalCase 列;snake_case 列由历史 CodeFirst 阶段补加但当前唯一 1 行全空。
+/// DB 存在新旧两套列:业务真实数据落在旧 PascalCase 列;snake_case 列由历史 CodeFirst 阶段补加但当前空表。
 /// 本 Entity 将业务字段 ColumnName 改回 DB 真实 PascalCase 列;组织字段保留 snake_case 映射。
-/// 加 [IgnoreTable] 防止 CodeFirst 继续 ALTER;移除 snake_case 唯一索引(DB 现有索引保持不变)。
+/// 加 [IgnoreTable] 防止 CodeFirst 继续 ALTER;表结构(含 tenant_id 列与唯一索引)由 UpdateScripts 管理。
+/// 租户:表 0 行时补 tenant_id 列,实现 ITenantIdFilter 启用全局 AOP 租户过滤;租户内唯一
+/// 由 UpdateScripts 索引 uk_BarCodeNbr_tenant_domain_customer_type (tenant_id, Domain, Customer, Type) 保证。
 /// </summary>
 [IgnoreTable]
 [SugarTable("BarCodeNbr", "条码规则主数据(复刻 BarCodeNbr)")]
-public class AdoS0BarCodeNbr
+public class AdoS0BarCodeNbr : ITenantIdFilter
 {
     [SugarColumn(ColumnName = "RecID", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true)]
     public long Id { get; set; }
@@ -103,6 +105,10 @@ public class AdoS0BarCodeNbr
     [SugarColumn(ColumnName = "UpdateTime", ColumnDescription = "更新时间", IsNullable = true)]
     public DateTime? UpdateTime { get; set; }
 
+    /// <summary>租户 ID(映射既有 tenant_id 列;实现 ITenantIdFilter,启用全局 AOP 租户隔离)。</summary>
+    [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
+    public long? TenantId { get; set; }
+
     /// <summary>列表展示:供应商简称(不落库,来自 SuppMaster 关联)</summary>
     [SugarColumn(IsIgnore = true)]
     public string? SuppName { get; set; }

+ 8 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0TaskAssignment.cs

@@ -1,14 +1,20 @@
 namespace Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 
 /// <summary>
-/// 物料状态任务指派(复刻 wms_rwzp)
+/// 物料状态任务指派(复刻 wms_rwzp)。
+/// 租户:wms_rwzp 属租户级业务表 → 实现 ITenantIdFilter,启用全局 AOP 租户隔离;
+/// Controller 另显式按 TenantId 收口(兼顾超管未选租户拒绝)。租户内唯一由 UpdateScripts 索引保证。
 /// </summary>
 [SugarTable("wms_rwzp", "物料状态任务指派(复刻 wms_rwzp)")]
-public class AdoS0TaskAssignment
+public class AdoS0TaskAssignment : ITenantIdFilter
 {
     [SugarColumn(ColumnName = "rec_id", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
     public long Id { get; set; }
 
+    /// <summary>租户 ID(映射 tenant_id 列;实现 ITenantIdFilter,启用全局 AOP 租户隔离)。</summary>
+    [SugarColumn(ColumnName = "tenant_id", ColumnDescription = "租户 ID", ColumnDataType = "bigint", IsNullable = false)]
+    public long? TenantId { get; set; }
+
     [SugarColumn(ColumnName = "company_ref_id", ColumnDescription = "关联公司 ID", ColumnDataType = "bigint")]
     public long CompanyRefId { get; set; }
 

+ 26 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/IpqcInspectionDispositionService.cs

@@ -28,6 +28,9 @@ public class IpqcInspectionDispositionService : IDynamicApiController, ITransien
         IpqcInspectionFlowConst.DispTypeOther,
     };
 
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
     private readonly ISqlSugarClient _db;
     private readonly FlowEngineService _flowEngine;
     private readonly UserManager _userManager;
@@ -39,6 +42,17 @@ public class IpqcInspectionDispositionService : IDynamicApiController, ITransien
         _userManager = userManager;
     }
 
+    /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
+    private long ResolveTenantOrThrow()
+    {
+        var tid = _userManager.TenantId;
+        if (tid <= 0)
+            throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户");
+        if (_userManager.SuperAdmin && tid == MainTenantId)
+            throw Oops.Oh("超级管理员操作前必须选择目标租户");
+        return tid;
+    }
+
     /// <summary>
     /// 质量处置详情(只读,供前端展示已提交处置)。查不到返回 null。
     /// </summary>
@@ -47,9 +61,11 @@ public class IpqcInspectionDispositionService : IDynamicApiController, ITransien
     public async Task<IpqcDispositionOutput?> GetState([FromQuery] long id)
     {
         if (id <= 0) return null;
+        // 严格租户隔离:ClearFilter 移除框架租户过滤器后,必须补 TenantId 谓词,防止跨租户读他租户处置。
+        var tid = ResolveTenantOrThrow();
         return await _db.Queryable<AdoS6IpqcQualityDisposition>()
             .ClearFilter()
-            .Where(x => x.InspectionId == id && x.Status == 1)
+            .Where(x => x.InspectionId == id && x.TenantId == tid && x.Status == 1)
             .OrderByDescending(x => x.Id)
             .Select(x => new IpqcDispositionOutput
             {
@@ -80,6 +96,8 @@ public class IpqcInspectionDispositionService : IDynamicApiController, ITransien
     public async Task<IpqcDispositionOutput?> Submit([FromBody] IpqcSubmitDispositionInput input)
     {
         var billNo = await EnsureBillExistsAsync(input.Id);
+        // 写库前严格解析可信租户(无 Token / 超管未选主租户 → 抛错,不落库、不查他租户)。
+        var tid = ResolveTenantOrThrow();
 
         // 参数校验
         var type = (input.DispositionType ?? string.Empty).Trim().ToUpperInvariant();
@@ -100,10 +118,10 @@ public class IpqcInspectionDispositionService : IDynamicApiController, ITransien
         if (myTask == null) throw Oops.Oh("当前用户没有待处理的质量处置任务(需质量工程师角色成员)");
         if (myTask.NodeId != IpqcInspectionFlowConst.NodeDisposition) throw Oops.Oh("当前不在质量处置节点");
 
-        // 一单一有效处置
+        // 一单一有效处置(ClearFilter 后显式补 TenantId 谓词,防止跨租户误判/命中)
         var hasEffective = await _db.Queryable<AdoS6IpqcQualityDisposition>()
             .ClearFilter()
-            .AnyAsync(x => x.InspectionId == input.Id && x.Status == 1);
+            .AnyAsync(x => x.InspectionId == input.Id && x.TenantId == tid && x.Status == 1);
         if (hasEffective) throw Oops.Oh("该检验单已有有效质量处置,不可重复提交");
 
         var now = DateTime.Now;
@@ -111,7 +129,7 @@ public class IpqcInspectionDispositionService : IDynamicApiController, ITransien
         var disposition = new AdoS6IpqcQualityDisposition
         {
             Id = YitIdHelper.NextId(),
-            TenantId = _userManager.TenantId,
+            TenantId = tid,
             OrgId = inst.OrgId,
             InspectionId = input.Id,
             InspectionNo = billNo,
@@ -145,7 +163,7 @@ public class IpqcInspectionDispositionService : IDynamicApiController, ITransien
                     new("@st", IpqcInspectionFlowConst.StatusDisposed),
                     new("@now", now),
                     new("@id", input.Id),
-                    new("@tid", _userManager.TenantId),
+                    new("@tid", tid),
                 });
             // 推进 N3 → end
             await _flowEngine.Approve(myTask.Id, input.Comment);
@@ -161,9 +179,11 @@ public class IpqcInspectionDispositionService : IDynamicApiController, ITransien
     {
         if (id <= 0) throw Oops.Oh("检验单 id 非法");
         // 强制租户隔离:qms_gcjyd 全库共享无框架租户过滤器,裸 SQL 显式按当前用户租户校验。
+        // 严格解析租户(无 Token / 超管未选主租户 → 拒绝,不回退默认)。
+        var tid = ResolveTenantOrThrow();
         var rows = await _db.Ado.SqlQueryAsync<string>(
             "SELECT djbh FROM qms_gcjyd WHERE id=@id AND tenant_id=@tid LIMIT 1",
-            new List<SugarParameter> { new("@id", id), new("@tid", _userManager.TenantId) });
+            new List<SugarParameter> { new("@id", id), new("@tid", tid) });
         if (rows.Count == 0) throw Oops.Oh("检验单不存在");
         return rows[0] ?? id.ToString();
     }

+ 35 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/IpqcInspectionFlowService.cs

@@ -24,6 +24,9 @@ namespace Admin.NET.Plugin.AiDOP.Manufacturing;
 [NonUnify]
 public class IpqcInspectionFlowService : IDynamicApiController, ITransient
 {
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
     private readonly ISqlSugarClient _db;
     private readonly FlowEngineService _flowEngine;
     private readonly UserManager _userManager;
@@ -35,6 +38,17 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
         _userManager = userManager;
     }
 
+    /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
+    private long ResolveTenantOrThrow()
+    {
+        var tid = _userManager.TenantId;
+        if (tid <= 0)
+            throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户");
+        if (_userManager.SuperAdmin && tid == MainTenantId)
+            throw Oops.Oh("超级管理员操作前必须选择目标租户");
+        return tid;
+    }
+
     /// <summary>
     /// 检验单流程状态(只读,供前端按钮控制)。
     /// </summary>
@@ -96,6 +110,7 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
                 throw Oops.Oh("当前不在检验录入节点,无法提交检验结果");
 
             // 写检验结果(仅 qms_gcjyd 白名单列 jgpd/jyhgsl/jybhgsl/ybl/jyr/bz;jgpd 可空不强写)
+            var tid = ResolveTenantOrThrow();
             await _db.Ado.ExecuteCommandAsync(
                 "UPDATE qms_gcjyd SET jgpd=@jgpd, jyhgsl=@jyhgsl, jybhgsl=@jybhgsl, ybl=@ybl, jyr=@jyr, bz=@bz WHERE id=@id AND tenant_id=@tid",
                 new List<SugarParameter>
@@ -107,7 +122,7 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
                     new("@jyr", inspectorName),
                     new("@bz", input.Bz),
                     new("@id", input.Id),
-                    new("@tid", _userManager.TenantId),
+                    new("@tid", tid),
                 });
 
             // 落轻量扩展表(提交人/提交时间/业务状态,不回写 qms_gcjyd.status)
@@ -142,9 +157,12 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
         {
             // 整批判定 jgpd 写 qms_gcjyd 白名单列(可空,不硬编码枚举;由前端传测试/已确认合法值)
             if (input.Jgpd.HasValue)
+            {
+                var tid = ResolveTenantOrThrow();
                 await _db.Ado.ExecuteCommandAsync(
                     "UPDATE qms_gcjyd SET jgpd=@jgpd WHERE id=@id AND tenant_id=@tid",
-                    new List<SugarParameter> { new("@jgpd", input.Jgpd.Value), new("@id", input.Id), new("@tid", _userManager.TenantId) });
+                    new List<SugarParameter> { new("@jgpd", input.Jgpd.Value), new("@id", input.Id), new("@tid", tid) });
+            }
 
             // 先写 disposition_required(GW_RESULT 网关判据,Approve 时 GetBizData 读取)再推进
             await UpsertFlowStateAsync(input.Id, billNo, inst.Id, businessStatus, nextNode, now,
@@ -195,10 +213,11 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
     {
         if (id <= 0) throw Oops.Oh("检验单 id 非法");
         // 强制租户隔离:qms_gcjyd 全库共享无框架租户过滤器,裸 SQL 必须显式按当前用户租户校验,
-        // 防止跨租户读到/操作他租户检验单。
+        // 防止跨租户读到/操作他租户检验单。严格解析租户(无 Token / 超管未选主租户 → 拒绝,不回退默认)。
+        var tid = ResolveTenantOrThrow();
         var rows = await _db.Ado.SqlQueryAsync<string>(
             "SELECT djbh FROM qms_gcjyd WHERE id=@id AND tenant_id=@tid LIMIT 1",
-            new List<SugarParameter> { new("@id", id), new("@tid", _userManager.TenantId) });
+            new List<SugarParameter> { new("@id", id), new("@tid", tid) });
         if (rows.Count == 0) throw Oops.Oh("检验单不存在");
         return rows[0] ?? id.ToString();
     }
@@ -249,9 +268,11 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
         string? currentNode, DateTime now, bool isSubmit, string? inspectorName = null, string? supervisorName = null,
         string? reviewResult = null, string? returnReason = null, int? dispositionRequired = null)
     {
+        // 显式租户隔离:ClearFilter 移除框架租户过滤器后,必须补 TenantId 谓词,防止跨租户命中他租户流程状态行。
+        var tid = ResolveTenantOrThrow();
         var existing = await _db.Queryable<AdoS6IpqcInspectionFlowState>()
             .ClearFilter()
-            .Where(x => x.InspectionId == inspectionId)
+            .Where(x => x.InspectionId == inspectionId && x.TenantId == tid)
             .FirstAsync();
 
         if (existing == null)
@@ -259,7 +280,7 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
             var row = new AdoS6IpqcInspectionFlowState
             {
                 Id = YitIdHelper.NextId(),
-                TenantId = _userManager.TenantId,
+                TenantId = tid,
                 InspectionId = inspectionId,
                 InspectionNo = billNo,
                 BizType = IpqcInspectionFlowConst.BizType,
@@ -317,9 +338,11 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
         var output = new IpqcFlowStateOutput { BizId = bizId };
         var inst = await GetLatestInstanceAsync(bizId);
 
+        // 显式租户隔离:ClearFilter 移除框架租户过滤器后,必须补 TenantId 谓词,防止跨租户读到他租户流程状态/处置。
+        var tid = ResolveTenantOrThrow();
         var state = await _db.Queryable<AdoS6IpqcInspectionFlowState>()
             .ClearFilter()
-            .Where(x => x.InspectionId == bizId)
+            .Where(x => x.InspectionId == bizId && x.TenantId == tid)
             .FirstAsync();
         if (state != null)
         {
@@ -329,7 +352,7 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
             output.DispositionRequired = state.DispositionRequired;
         }
         output.HasDisposition = await _db.Queryable<AdoS6IpqcQualityDisposition>()
-            .ClearFilter().AnyAsync(x => x.InspectionId == bizId && x.Status == 1);
+            .ClearFilter().AnyAsync(x => x.InspectionId == bizId && x.TenantId == tid && x.Status == 1);
 
         if (inst == null)
         {
@@ -395,13 +418,14 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
     {
         try
         {
+            // 严格租户隔离:裸 SQL 读 qms_gcjyd 必须显式按当前用户租户校验,防止跨租户读他租户单据。
+            long tenantId = ResolveTenantOrThrow();
             var row = await _db.Ado.SqlQuerySingleAsync<IpqcOutboxBillRow>(
-                "SELECT id AS Id, djbh AS BillNo, jgpd AS Jgpd, jyhgsl AS Jyhgsl, jybhgsl AS Jybhgsl FROM qms_gcjyd WHERE id=@id LIMIT 1",
-                new List<SugarParameter> { new("@id", billId) });
+                "SELECT id AS Id, djbh AS BillNo, jgpd AS Jgpd, jyhgsl AS Jyhgsl, jybhgsl AS Jybhgsl FROM qms_gcjyd WHERE id=@id AND tenant_id=@tid LIMIT 1",
+                new List<SugarParameter> { new("@id", billId), new("@tid", tenantId) });
             if (row == null) return;
 
             string idem = string.IsNullOrWhiteSpace(billNo) ? billId.ToString() : billNo;
-            long tenantId = _userManager.TenantId;
             var payload = JsonSerializer.Serialize(new
             {
                 path = "/ipqc/result",