Parcourir la source

fix(s0): 修复产品设计跨租户BOM查询

产品设计的「根据产品编码获取BOM和工艺」接口,两段裸 SQL 通篇没有租户谓词。
它们走 Ado.SqlQueryAsync,完全绕过 SqlSugar 的查询过滤器;而该控制器又是
整类匿名,匿名请求下租户过滤器根本不会注册(它要求 JWT 带租户声明),
连兜底都没有。也就是说未认证的调用者可以拖走全部租户的物料清单与工艺路线。

缺谓词共七处,不是两处:递归锚点、递归成员、递归成员里校验父件是否自制的
物料主数据关联、最外层取物料名称的关联、最外层取工序覆盖的关联、工艺路线
主查询、工艺路线关联产线产能。七处缺一不可。

只在最外层加租户过滤是无效的,有三层原因。一是语法上不成立,递归结果集的
列清单里根本没有租户列。二是即便把租户列加进去、只在最外层过滤,仍会残留
血统不明的自家行:实测料号 3124C0015,外层过滤版比正确版多出十五、十五、
一百五十行,这些行自身的租户就等于查询租户、外层删不掉,但它们是靠经过外
租户的边才被走到的节点——物料主数据有六百六十三个编码跨租户重复,递归中间
层一旦串到别人的图上,回落到本租户编码时就产生这种行。三是最外层那个取物料
名称的左连接本身就是独立的放大器,实测把四百五十二行的递归结果膨胀到一千
一百二十行。

三处左连接的租户谓词必须放在连接条件里而不是 WHERE 里,否则左连接会退化成
内连接,没有物料主数据或没有产线的行会整行消失。

实测修复效果,十二个组合全部命中预期:3124C0015 从一千一百二十行降到按租户
的三十二、三十一、二十二、二十二;91CC0231 从二百六十行降到十六、六、六、六,
递归深度也从二降回一;1AB9275 从四十八行降到各三行。最强的断言是泄漏子件:
租户 838257237606469 查 91CC0231 时,含外租户子件的行数从一百六十降到零,
返回的子件集合恰好是预期的六个。无污染基线料号 1A0339H 修复前后完全一致,
十七行、十七个子件、数量合计 85.0000。工艺路线从三十六、二十八、十六降到
九、七、四。

语义未变:层级关系、排序、有效状态过滤、结构类型、只取自制件的条件、数量
计算式、工序覆盖逻辑、投影列集合全部逐字保留,只新增租户合取项。

残留一项未在本轮处理:该控制器的匿名特性仍在。修完 SQL 后匿名调用会返回零行
(租户为零,谓词恒不成立),泄漏已堵,但端点本身的未认证暴露没有解决。前端走
共享请求实例、无条件携带令牌,所以移除该特性对现有页面无影响,但那属于跨模块
的认证变更,需单独评估。
YY968XX il y a 1 jour
Parent
commit
84b1fde906

+ 31 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Order/ProductDesignService.cs

@@ -238,16 +238,35 @@ public class ProductDesignService : IDynamicApiController, ITransient
         if (string.IsNullOrWhiteSpace(itemNum))
             return new BomAndRoutingOutput();
 
+        // 🔴 租户硬门:本方法的两段裸 SQL 走 _db.Ado.SqlQueryAsync,**完全绕过** SqlSugar 的
+        //    QueryFilter(后者只作用于 Queryable<T>),必须自己带 tenant_id。
+        //    且本控制器是 [AllowAnonymous],匿名请求下 SqlSugarSetup 的 ITenantIdFilter
+        //    根本不会注册(它要求 JWT 里有 TenantId claim),连兜底都没有。
+        //    tenantId 为 0(匿名/无 claim)时下面所有谓词都匹配不到行 —— fail-closed,符合预期。
+        var tenantId = _userManager.TenantId;
+
         // 查询图纸设计周期
         var drawingDesignCycle = await _db.Queryable<AdoS0ItemMaster>()
-            .Where(x => x.ItemNum == itemNum && x.IsActive == true)
+            .Where(x => x.TenantId == tenantId && x.ItemNum == itemNum && x.IsActive == true)
             .Select(x => x.DrawingDesign)
             .FirstAsync();
 
+        // 🔴 七处 tenant_id 谓词缺一不可(2026-09-08 实测,逐条见下)。
+        //
+        //    **只在最外层 WHERE 过滤是无效的**,有三层原因:
+        //    ① 语法上不成立 —— CTE 的列清单里根本没有 tenant_id;
+        //    ② 即便把 tenant_id 加进 CTE 列再只在外层过滤,仍会残留"血统不明的自家行" ——
+        //       实测料号 3124C0015:外层过滤版比正确版多出 15/15/150 行,这些行自身的
+        //       tenant_id 等于查询租户、外层删不掉,但它们是靠"经过外租户的边"才被走到的节点
+        //       (ItemMaster 有 663 个跨租户重码,递归中间层一旦串到别人的图上就会这样回落);
+        //    ③ 最外层的 LEFT JOIN ItemMaster 本身就是独立放大器 —— 实测 452 行 CTE 经它膨胀到 1120 行。
+        //
+        //    ⚠️ ④⑤⑦ 三处**必须放在 ON 而不是 WHERE**:放 WHERE 会把无匹配的 NULL 行滤掉,
+        //    LEFT JOIN 退化成 INNER JOIN,没有物料主数据/产线的 BOM 行会整行消失。
         var bomSql = @"
 WITH RECURSIVE temp(ParentItem,ComponentItem,op,qty,StructureType,QtyConsumed) AS (
     SELECT ParentItem,ComponentItem,op,qty,StructureType,QtyConsumed
-    FROM ProductStructureMaster WHERE ParentItem=@itemNum
+    FROM ProductStructureMaster WHERE ParentItem=@itemNum AND tenant_id=@tenantId
     UNION ALL
     SELECT c.ParentItem,c.ComponentItem,c.op,
            CAST(c.qty+(c.qty*(c.Scrap+c.QtyExchd)/100) AS DECIMAL(15,8)) AS qty,
@@ -255,21 +274,27 @@ WITH RECURSIVE temp(ParentItem,ComponentItem,op,qty,StructureType,QtyConsumed) A
     FROM ProductStructureMaster c
     INNER JOIN temp p ON c.ParentItem=p.ComponentItem
     INNER JOIN ItemMaster parentIm ON c.ParentItem=parentIm.ItemNum AND parentIm.PurMfg<>'P'
+        AND parentIm.tenant_id=@tenantId
+    WHERE c.tenant_id=@tenantId
 )
 SELECT psm.ParentItem,psm.ComponentItem AS ItemNum,im.Descr AS ItemName,
        CASE WHEN IFNULL(pso.Op,0)=0 THEN psm.Op ELSE pso.Op END AS Op,
        psm.qty AS Qty,psm.StructureType,im.EMTType AS EmtType,psm.QtyConsumed
 FROM temp psm
 LEFT JOIN ItemMaster im ON psm.ComponentItem=im.ItemNum
+    AND im.tenant_id=@tenantId
 LEFT JOIN ProductStructureOp pso ON pso.ParentItem=psm.ParentItem
-    AND pso.ComponentItem=psm.ComponentItem AND pso.ProductItem=@itemNum";
+    AND pso.ComponentItem=psm.ComponentItem AND pso.ProductItem=@itemNum
+    AND pso.tenant_id=@tenantId";
 
         var routingSql = @"
 SELECT r.Descr,r.Op,r.ParentOp,CAST(r.MilestoneOp AS CHAR(5)) AS MilestoneOp,p.Line,r.RouteCode
-FROM RoutingOpDetail as r left join ProdLineDetail as p on r.RoutingCode=p.Part and r.Op=p.Op WHERE RoutingCode=@itemNum ORDER BY r.Op";
+FROM RoutingOpDetail as r left join ProdLineDetail as p on r.RoutingCode=p.Part and r.Op=p.Op
+    and p.tenant_id=@tenantId
+WHERE r.RoutingCode=@itemNum AND r.tenant_id=@tenantId ORDER BY r.Op";
 
-        var boms = await _db.Ado.SqlQueryAsync<BomQueryRow>(bomSql, new { itemNum });
-        var routings = await _db.Ado.SqlQueryAsync<RoutingQueryRow>(routingSql, new { itemNum });
+        var boms = await _db.Ado.SqlQueryAsync<BomQueryRow>(bomSql, new { itemNum, tenantId });
+        var routings = await _db.Ado.SqlQueryAsync<RoutingQueryRow>(routingSql, new { itemNum, tenantId });
 
         return new BomAndRoutingOutput
         {