Ver código fonte

fix(s5): enforce tenant isolation on warehouse queries (server 1.0.311 / Web 2.4.280)

5 个 S5 只读查询服务移除 [AllowAnonymous] + 租户改严格 JWT,堵匿名跨租户/全表读:
IqcInspBill / IncomingInspectionApplication / PendingInspection / InoutQuery / StockQuery。
另收口 IqcInspBillFlow(P1):写库前严格解析,超管未选不落库。

- 去 [AllowAnonymous];ResolveTenantOrThrow(严格 JWT,拒无Token/超管未选,无默认回退)
- inout/stock 补 tenant_id=@TenantId(InvTransHist/LocationMaster/GeneralizedCodeMaster)
- iqc/incoming/pending SQL 已按 tenant 过滤,仅换严格来源
- DTO 删 Input.TenantId;前端 5 API 去 withAidopTenantParams(不动公共实现)
- 不碰共享 helper/S1-S4/DDL/其它模块

运行时实测:匿名全401、认证只见本租户、传他租户tenantId无效、超管未选400。
YY968XX 2 dias atrás
pai
commit
9a3d73c700

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "admin.net",
   "type": "module",
-	"version": "2.4.279",
+	"version": "2.4.280",
   "packageManager": "pnpm@10.32.1",
   "lastBuildTime": "2026.03.15",
   "description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 1 - 2
Web/src/views/aidop/s5/api/incomingInspection.ts

@@ -1,5 +1,4 @@
 import service from '/@/utils/request';
-import { withAidopTenantParams } from '../../api/aidopTenant';
 
 export interface Paged<T> {
 	total: number;
@@ -27,7 +26,7 @@ export interface IncomingInspectionApplicationRow {
 export function fetchIncomingInspectionApplicationList(params: any) {
 	return service
 		.get<Paged<IncomingInspectionApplicationRow>>('/api/IncomingInspection/application/list', {
-			params: withAidopTenantParams(params),
+			params,
 		})
 		.then((r) => r.data);
 }

+ 1 - 2
Web/src/views/aidop/s5/api/inoutQuery.ts

@@ -1,5 +1,4 @@
 import service from '/@/utils/request';
-import { withAidopTenantParams } from '../../api/aidopTenant';
 
 export interface InoutQueryPaged<T> {
 	total: number;
@@ -58,7 +57,7 @@ export interface InoutQueryTransTypeOption {
 
 export function fetchInoutQueryList(params: any) {
 	return service
-		.get<InoutQueryPaged<InoutQueryRow>>('/api/S5InoutQuery/list', { params: withAidopTenantParams(params) })
+		.get<InoutQueryPaged<InoutQueryRow>>('/api/S5InoutQuery/list', { params })
 		.then((r) => r.data);
 }
 

+ 2 - 3
Web/src/views/aidop/s5/api/iqcInspBill.ts

@@ -1,5 +1,4 @@
 import service from '/@/utils/request';
-import { withAidopTenantParams } from '../../api/aidopTenant';
 
 export interface IqcInspBillPaged<T> {
 	total: number;
@@ -55,7 +54,7 @@ export interface IqcInspBillRow {
 
 export function fetchIqcInspBillList(params: any) {
 	return service
-		.get<IqcInspBillPaged<IqcInspBillRow>>('/api/S5IqcInspBill/list', { params: withAidopTenantParams(params) })
+		.get<IqcInspBillPaged<IqcInspBillRow>>('/api/S5IqcInspBill/list', { params })
 		.then((r) => r.data);
 }
 
@@ -118,6 +117,6 @@ export interface IqcInspBillDetail {
 
 export function fetchIqcInspBillDetail(id: number) {
 	return service
-		.get<IqcInspBillDetail | null>('/api/S5IqcInspBill/detail', { params: withAidopTenantParams({ id }) })
+		.get<IqcInspBillDetail | null>('/api/S5IqcInspBill/detail', { params: { id } })
 		.then((r) => r.data);
 }

+ 1 - 2
Web/src/views/aidop/s5/api/pendingInspection.ts

@@ -1,5 +1,4 @@
 import service from '/@/utils/request';
-import { withAidopTenantParams } from '../../api/aidopTenant';
 
 export interface PagedWithTotalQty<T> {
 	total: number;
@@ -50,7 +49,7 @@ export interface PendingInspectionSupplierOption {
 
 export function fetchPendingInspectionList(params: any) {
 	return service
-		.get<PagedWithTotalQty<PendingInspectionRow>>('/api/S5PendingInspection/list', { params: withAidopTenantParams(params) })
+		.get<PagedWithTotalQty<PendingInspectionRow>>('/api/S5PendingInspection/list', { params })
 		.then((r) => r.data);
 }
 

+ 1 - 2
Web/src/views/aidop/s5/api/stockQuery.ts

@@ -1,5 +1,4 @@
 import service from '/@/utils/request';
-import { withAidopTenantParams } from '../../api/aidopTenant';
 
 export interface StockQueryPaged<T> {
 	total: number;
@@ -55,7 +54,7 @@ export interface StockQueryLocationOption {
 
 export function fetchStockQueryList(params: any) {
 	return service
-		.get<StockQueryPaged<StockQueryRow>>('/api/S5StockQuery/list', { params: withAidopTenantParams(params) })
+		.get<StockQueryPaged<StockQueryRow>>('/api/S5StockQuery/list', { params })
 		.then((r) => r.data);
 }
 

+ 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.310</AssemblyVersion>
-    <FileVersion>1.0.310</FileVersion>
-    <Version>1.0.310</Version>
+    <AssemblyVersion>1.0.311</AssemblyVersion>
+    <FileVersion>1.0.311</FileVersion>
+    <Version>1.0.311</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 0 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IncomingInspectionApplicationDto.cs

@@ -12,9 +12,6 @@ public class IncomingInspectionApplicationListInput
     /// <summary>每页条数</summary>
     public int PageSize { get; set; } = 10;
 
-    /// <summary>租户(WP0-D4;前端 withAidopTenantParams 注入)</summary>
-    public long? TenantId { get; set; }
-
     /// <summary>单据编号(FBILLNO,模糊匹配)</summary>
     public string? BillNo { get; set; }
 

+ 1 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/InoutQueryDto.cs

@@ -8,7 +8,7 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
 /// 进出存口径由 SQL 内定义:期初=BeginBalance;入库=QtyChange&gt;0?QtyChange:0;
 /// 出库=QtyChange&lt;0?abs(QtyChange):0;期末=BeginBalance+QtyChange。
 /// Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析,默认 8010。
-/// InvTransHist 无 tenant_id;TenantId 仅为前端 withAidopTenantParams 注入的透传参数,本查询不据其过滤
+/// 安全:InvTransHist 含 tenant_id,服务端按认证后可信 JWT 租户强制过滤,不接收前端传入的 tenantId
 /// </summary>
 public class InoutQueryListInput
 {
@@ -38,9 +38,6 @@ public class InoutQueryListInput
 
     /// <summary>创建用户(InvTransHist.CreateUser,模糊匹配)</summary>
     public string? CreateUser { get; set; }
-
-    /// <summary>租户 ID(前端 withAidopTenantParams 注入;InvTransHist 无租户列,本查询忽略)</summary>
-    public long? TenantId { get; set; }
 }
 
 /// <summary>

+ 1 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcInspBillDto.cs

@@ -4,7 +4,7 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
 /// S5 来料检验单(qms_qcp_inspbill)只读列表 查询入参。
 ///
 /// 数据源:aidopdev.qms_qcp_inspbill 单表直读(不 join 申请主表/明细/SuppMaster/ApprovalFlow)。
-/// WP0-D4:按 tenant_id 过滤;TenantId 由前端 withAidopTenantParams 注入
+/// 按 tenant_id 过滤;租户由服务端严格从认证后 JWT 解析(不再接收前端 tenantId)
 /// pd/clfs/FBILLSTATUS 字典未闭合,raw 展示。仅 SELECT。
 /// </summary>
 public class IqcInspBillListInput
@@ -65,9 +65,6 @@ public class IqcInspBillListInput
 
     /// <summary>排序方向(asc/desc,非法回落 desc)</summary>
     public string? OrderDir { get; set; }
-
-    /// <summary>租户 ID——本批不生效(表无 tenant_id),接收不过滤</summary>
-    public long? TenantId { get; set; }
 }
 
 /// <summary>

+ 2 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/PendingInspectionDto.cs

@@ -5,8 +5,8 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
 ///
 /// 数据源:aidopdev.MissedPrint(Status='I' 暂收待检、Qty>0、Location 空),按存储过程
 /// pr_WMS_GetMissedTempQty 口径 join qms 检验单 / ItemMaster / SuppMaster 聚合。
-/// MissedPrint 无 tenant_id 列;Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析
-/// TenantId 仅为前端 withAidopTenantParams 注入的透传参数,本查询不据其过滤
+/// MissedPrint 无 tenant_id 列(按 Domain 公司域隔离);Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析
+/// 租户由服务端严格从认证后 JWT 解析并施加于 tenant-safe 的 qms JOIN 表(不接收前端 tenantId)
 /// </summary>
 public class PendingInspectionListInput
 {
@@ -33,9 +33,6 @@ public class PendingInspectionListInput
 
     /// <summary>供应商(MissedPrint.Supply,精确匹配,下拉选择)</summary>
     public string? Supplier { get; set; }
-
-    /// <summary>租户 ID(前端 withAidopTenantParams 注入;MissedPrint 无租户列,本查询忽略)</summary>
-    public long? TenantId { get; set; }
 }
 
 /// <summary>

+ 1 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/StockQueryDto.cs

@@ -6,7 +6,7 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
 /// 数据源:aidopdev.LocationMaster ⋈ LocationDetail(现存量),按存储过程 pr_WMS_GetMissedLocationQty
 /// 口径过滤 (AvailStatusQty+Assay+FreezeQty)&gt;0,join ItemMaster(名称/单位)、富集 MissedPrint(货架/版本/供应商)。
 /// Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析,默认 8010。
-/// LocationDetail 无 tenant_id 列;TenantId 仅为前端 withAidopTenantParams 注入的透传参数,本查询不据其过滤
+/// 安全:LocationMaster 含 tenant_id,服务端按认证后可信 JWT 租户强制过滤基础层(LocationDetail/MissedPrint 经父键约束),不接收前端传入的 tenantId
 /// </summary>
 public class StockQueryListInput
 {
@@ -39,9 +39,6 @@ public class StockQueryListInput
 
     /// <summary>冻结库存大于(LocationDetail.FreezeQty &gt; 值)</summary>
     public decimal? FrozenGt { get; set; }
-
-    /// <summary>租户 ID(前端 withAidopTenantParams 注入;LocationDetail 无租户列,本查询忽略)</summary>
-    public long? TenantId { get; set; }
 }
 
 /// <summary>

+ 20 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IncomingInspectionApplicationService.cs

@@ -11,22 +11,37 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// 不查询 FORGID / FBIZTYPE / FAPPLYUSER —— 名称解析源(bos_org / qcbd_biztype / bos_user)
 /// 当前不在 aidopdev,不做脑补 join、不显示裸 ID。
 ///
-/// 租户:qms_qcp_inspecapplyn 已补 tenant_id 列,按当前租户过滤(AllowAnonymous → AidopTenantHelper.Resolve);
+/// 租户:qms_qcp_inspecapplyn 已补 tenant_id 列,按严格可信租户过滤(需认证,租户仅取认证后 JWT,忽略前端 tenantId);
 /// 无 domain / factory 列,不新增前端 factory 选择,亦不脑补工厂维度。
 ///
 /// 本接口只读:仅 SELECT,无新增/编辑/删除/任务拆分/状态回写。
 /// </summary>
 [ApiDescriptionSettings(Order = 305, Description = "来料检验申请")]
 [Route("api/IncomingInspection")]
-[AllowAnonymous]
 [NonUnify]
 public class IncomingInspectionApplicationService : IDynamicApiController, ITransient
 {
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
     private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
 
-    public IncomingInspectionApplicationService(ISqlSugarClient db)
+    public IncomingInspectionApplicationService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
+        _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>
@@ -40,8 +55,8 @@ public class IncomingInspectionApplicationService : IDynamicApiController, ITran
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
-        // 同库分租户:qms_qcp_inspecapplyn 已补 tenant_id 列,按当前租户过滤(AllowAnonymous → AidopTenantHelper.Resolve)
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        // 同库分租户:qms_qcp_inspecapplyn 已补 tenant_id 列,按严格可信租户(仅认证后 JWT,忽略前端 tenantId)过滤,杜绝匿名跨租户读
+        var tenantId = ResolveTenantOrThrow();
         var where = new List<string> { "m.tenant_id = @TenantId" };
         var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
 

+ 32 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InoutQueryService.cs

@@ -13,19 +13,36 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析,默认 8010。
 /// 排序 a.RecID DESC(PK,近似交易时间倒序,兼顾 1M 行分页性能与稳定)。
 ///
-/// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转;InvTransHist 无 tenant_id,不做租户过滤。
+/// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转。
+/// 安全:InvTransHist / LocationMaster / GeneralizedCodeMaster 均含 tenant_id,主业务查询按当前租户强制过滤;
+/// 租户只来自认证后的可信 JWT(严格解析),忽略前端 tenantId,杜绝匿名/跨租户可见。
 /// </summary>
 [ApiDescriptionSettings(Order = 303, Description = "进出存查询")]
 [Route("api/S5InoutQuery")]
-[AllowAnonymous]
 [NonUnify]
 public class InoutQueryService : IDynamicApiController, ITransient
 {
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
     private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
 
-    public InoutQueryService(ISqlSugarClient db)
+    public InoutQueryService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
+        _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>解析公司域(GeneralizedCodeMaster SystemConfig/CompanyCode,默认 8010)。</summary>
@@ -48,10 +65,12 @@ public class InoutQueryService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
+        var tenantId = ResolveTenantOrThrow();
         var domain = await ResolveDomainAsync();
 
-        var where = new List<string> { "a.Domain = @Domain" };
-        var pars = new List<SugarParameter> { new("@Domain", domain) };
+        // 强制租户过滤:InvTransHist 含 tenant_id(约百万行、当前全 797),按可信 JWT 租户过滤,count 与 list 同 tid。
+        var where = new List<string> { "a.Domain = @Domain", "a.tenant_id = @TenantId" };
+        var pars = new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.MaterialCode))
         {
@@ -133,15 +152,17 @@ public class InoutQueryService : IDynamicApiController, ITransient
     [HttpGet("loc-options")]
     public async Task<object> GetLocationOptions()
     {
+        var tenantId = ResolveTenantOrThrow();
         var domain = await ResolveDomainAsync();
+        // LocationMaster 含 tenant_id(797:80 / 824:34),强制按可信租户过滤。
         var list = await _db.Ado.SqlQueryAsync<InoutQueryLocationOption>(
             """
             SELECT location AS Val, descr AS Label
             FROM LocationMaster
-            WHERE Domain = @Domain AND IFNULL(location,'') <> ''
+            WHERE Domain = @Domain AND tenant_id = @TenantId AND IFNULL(location,'') <> ''
             ORDER BY location
             """,
-            new List<SugarParameter> { new("@Domain", domain) });
+            new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) });
         return list;
     }
 
@@ -152,15 +173,17 @@ public class InoutQueryService : IDynamicApiController, ITransient
     [HttpGet("transtype-options")]
     public async Task<object> GetTransTypeOptions()
     {
+        var tenantId = ResolveTenantOrThrow();
         var domain = await ResolveDomainAsync();
+        // GeneralizedCodeMaster 含 tenant_id(797/824),强制按可信租户过滤。
         var list = await _db.Ado.SqlQueryAsync<InoutQueryTransTypeOption>(
             """
             SELECT Val AS Val, Comments AS Label
             FROM GeneralizedCodeMaster
-            WHERE FldName = 'TransType' AND Domain = @Domain AND IFNULL(Val,'') <> ''
+            WHERE FldName = 'TransType' AND Domain = @Domain AND tenant_id = @TenantId AND IFNULL(Val,'') <> ''
             ORDER BY Val
             """,
-            new List<SugarParameter> { new("@Domain", domain) });
+            new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) });
         return list;
     }
 }

+ 27 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillFlowService.cs

@@ -22,6 +22,9 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 [NonUnify]
 public class IqcInspBillFlowService : IDynamicApiController, ITransient
 {
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
     private readonly ISqlSugarClient _db;
     private readonly FlowEngineService _flowEngine;
     private readonly UserManager _userManager;
@@ -33,6 +36,17 @@ public class IqcInspBillFlowService : 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>
@@ -97,6 +111,9 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
             if (myTask.NodeId != IqcInspBillFlowConst.NodeInspect)
                 throw Oops.Oh("当前不在检验录入节点,无法提交检验结果");
 
+            // 写库前严格解析可信租户(无 Token / 超管未选主租户 → 抛错,不落库)
+            var tid = ResolveTenantOrThrow();
+
             // 写检验结果(仅 pd/dhsl/bhgsl/clfs)
             await _db.Ado.ExecuteCommandAsync(
                 "UPDATE qms_qcp_inspbill SET pd=@pd, dhsl=@dhsl, bhgsl=@bhgsl, clfs=@clfs WHERE id=@id AND tenant_id=@TenantId",
@@ -107,7 +124,7 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
                     new("@bhgsl", input.Bhgsl),
                     new("@clfs", input.Pd == 1 ? input.Clfs : null),
                     new("@id", input.Id),
-                    new("@TenantId", _userManager.TenantId),
+                    new("@TenantId", tid),
                 });
 
             // 推进 N1 → N2
@@ -177,10 +194,13 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
         foreach (var q in new[] { input.DispositionQty, input.ConcessionQty, input.SelectionQty, input.ScrapQty })
             if (q.HasValue && q.Value < 0) throw Oops.Oh("处置数量不能为负");
 
+        // 写库前严格解析可信租户(无 Token / 超管未选主租户 → 抛错,不落库)
+        var tid = ResolveTenantOrThrow();
+
         // 检验单必须不合格(合格单无 SQE 节点)
         var pdRows = await _db.Ado.SqlQueryAsync<int?>(
             "SELECT pd FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId LIMIT 1",
-            new List<SugarParameter> { new("@id", input.Id), new("@TenantId", _userManager.TenantId) });
+            new List<SugarParameter> { new("@id", input.Id), new("@TenantId", tid) });
         if (pdRows.FirstOrDefault() != 1) throw Oops.Oh("仅不合格检验单(pd=1)可做 SQE 处置");
 
         var inst = await GetLatestInstanceAsync(input.Id);
@@ -199,13 +219,13 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
         // 来源单号
         var srcRows = await _db.Ado.SqlQueryAsync<string>(
             "SELECT lydjbh FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId LIMIT 1",
-            new List<SugarParameter> { new("@id", input.Id), new("@TenantId", _userManager.TenantId) });
+            new List<SugarParameter> { new("@id", input.Id), new("@TenantId", tid) });
 
         var now = DateTime.Now;
         var disposition = new AdoS5IqcSqeDisposition
         {
             Id = YitIdHelper.NextId(),
-            TenantId = _userManager.TenantId,
+            TenantId = tid,
             OrgId = inst.OrgId,
             InspbillId = input.Id,
             InspbillNo = billNo,
@@ -245,9 +265,10 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
     private async Task<string> EnsureBillExistsAsync(long id)
     {
         if (id <= 0) throw Oops.Oh("检验单 id 非法");
+        var tid = ResolveTenantOrThrow();
         var rows = await _db.Ado.SqlQueryAsync<string>(
             "SELECT FBILLNO FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId LIMIT 1",
-            new List<SugarParameter> { new("@id", id), new("@TenantId", _userManager.TenantId) });
+            new List<SugarParameter> { new("@id", id), new("@TenantId", tid) });
         if (rows.Count == 0) throw Oops.Oh("检验单不存在");
         return rows[0] ?? id.ToString();
     }
@@ -383,7 +404,7 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
             if (row == null || row.Pd != 0) return; // 仅合格回写
 
             string billNo = string.IsNullOrWhiteSpace(row.BillNo) ? billId.ToString() : row.BillNo!;
-            long tenantId = _userManager.TenantId;
+            long tenantId = ResolveTenantOrThrow();
             var payload = JsonSerializer.Serialize(new
             {
                 path = "/iqc/result",

+ 22 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillService.cs

@@ -10,21 +10,36 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// 批次=pch;数量=FRINSQTY/jysl/dhsl/bhgsl;判定=pd(raw);处理方式=clfs(raw);状态=FBILLSTATUS(raw);
 /// 检验员=FINSPECTORID;检验起止=FINSPESTARTDATE/FINSPEENDDATE;创建=FCREATETIME。默认排序 FCREATETIME DESC, id DESC。
 ///
-/// tenant/组织:qms_qcp_inspbill 已补 tenant_id 列,按当前租户过滤(AllowAnonymous → AidopTenantHelper.Resolve);FORGID 当前全 NULL,不做 FORGID 过滤。
+/// tenant/组织:qms_qcp_inspbill 已补 tenant_id 列,按严格可信租户过滤(需认证,租户仅取认证后 JWT,忽略前端 tenantId);FORGID 当前全 NULL,不做 FORGID 过滤。
 /// 本服务仅 SELECT:无新增/编辑/删除/详情/判定录入/主管审核/SQE 处置/仓储通知/
 /// 库存事务/流程实例;不写 qms_qcp_inspbill、不改 FBIZTYPE、不碰 ApprovalFlow。pd/clfs/FBILLSTATUS 字典未闭合,raw 展示。
 /// </summary>
 [ApiDescriptionSettings(Order = 309, Description = "来料检验单列表")]
 [Route("api/S5IqcInspBill")]
-[AllowAnonymous]
 [NonUnify]
 public class IqcInspBillService : IDynamicApiController, ITransient
 {
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
     private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
 
-    public IqcInspBillService(ISqlSugarClient db)
+    public IqcInspBillService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
+        _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>排序字段白名单(前端 prop → 物理列),杜绝 orderBy 注入。</summary>
@@ -53,8 +68,8 @@ public class IqcInspBillService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 20 : (input.PageSize > 200 ? 200 : input.PageSize);
         var offset = (page - 1) * pageSize;
 
-        // 同库分租户:qms_qcp_inspbill 已补 tenant_id 列,按当前租户过滤(AllowAnonymous 口径经 AidopTenantHelper 解析)。FORGID 全 NULL 不做组织过滤。
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        // 同库分租户:qms_qcp_inspbill 已补 tenant_id 列,按严格可信租户(仅认证后 JWT,忽略前端 tenantId)过滤,杜绝匿名跨租户读。FORGID 全 NULL 不做组织过滤。
+        var tenantId = ResolveTenantOrThrow();
         var where = new List<string> { "ib.tenant_id = @TenantId" };
         var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
 
@@ -173,8 +188,8 @@ public class IqcInspBillService : IDynamicApiController, ITransient
     {
         if (id <= 0) return null;
 
-        // 同库分租户:详情按 id + 当前租户过滤,杜绝跨租户越权取单(最高优先越权点)。
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        // 同库分租户:详情按 id + 严格可信租户(仅认证后 JWT,忽略前端 tenantId)过滤,杜绝匿名跨租户越权取单(最高优先越权点)。
+        var tenantId = ResolveTenantOrThrow();
         var pars = new List<SugarParameter> { new("@id", id), new("@TenantId", tenantId) };
         var list = await _db.Ado.SqlQueryAsync<IqcInspBillDetailRow>(
             """

+ 26 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PendingInspectionService.cs

@@ -15,15 +15,30 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// </summary>
 [ApiDescriptionSettings(Order = 301, Description = "暂收在检列表")]
 [Route("api/S5PendingInspection")]
-[AllowAnonymous]
 [NonUnify]
 public class PendingInspectionService : IDynamicApiController, ITransient
 {
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
     private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
 
-    public PendingInspectionService(ISqlSugarClient db)
+    public PendingInspectionService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
+        _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>解析公司域(GeneralizedCodeMaster SystemConfig/CompanyCode,默认 8010)。</summary>
@@ -55,9 +70,11 @@ public class PendingInspectionService : IDynamicApiController, ITransient
             "m.Qty > 0",
             "IFNULL(m.Location,'') = ''",
         };
-        // 同库分租户:主表 MissedPrint 无 tenant_id(用 Domain 隔离);LEFT JOIN 的 qms 申请/明细表已补 tenant_id,
-        // 租户过滤置于 JOIN ON(避免退化为 INNER JOIN 丢失 MissedPrint 锚行)。
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        // 同库分租户:主表 MissedPrint 无 tenant_id(只能靠 Domain 公司域隔离,无法做租户行级过滤);
+        // LEFT JOIN 的 qms 申请/明细表已补 tenant_id,租户过滤置于 JOIN ON(避免退化为 INNER JOIN 丢失 MissedPrint 锚行)。
+        // 租户改用严格可信解析(仅认证后 JWT,忽略前端 tenantId);配合类级已去 [AllowAnonymous],杜绝匿名跨租户读。
+        // 残留边界:MissedPrint 锚行本身仍按 Domain(非租户)可见,此为旧系统 pr_WMS_GetMissedTempQty 无租户列的既有约束。
+        var tenantId = ResolveTenantOrThrow();
         var pars = new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.DemandNo))
@@ -147,8 +164,8 @@ public class PendingInspectionService : IDynamicApiController, ITransient
     [HttpGet("status-options")]
     public async Task<object> GetStatusOptions()
     {
-        // 同库分租户:qms_qcp_insappnentry 已补 tenant_id,选项按当前租户收敛。
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        // 同库分租户:qms_qcp_insappnentry 已补 tenant_id,选项按严格可信租户(仅认证后 JWT,忽略前端 tenantId)收敛。
+        var tenantId = ResolveTenantOrThrow();
         var list = await _db.Ado.SqlQueryAsync<PendingInspectionStatusOption>(
             """
             SELECT DISTINCT FINSPECTSTATUS AS Val
@@ -167,6 +184,8 @@ public class PendingInspectionService : IDynamicApiController, ITransient
     [HttpGet("supplier-options")]
     public async Task<object> GetSupplierOptions()
     {
+        // 残留边界(注明):本选项来自 MissedPrint(无 tenant_id)+ SuppMaster(无 tenant_id),无 tenant-safe 父键可挂载租户过滤,
+        // 只能按 Domain 公司域收敛,无法做租户行级过滤(既有约束)。类级已去 [AllowAnonymous] → 需认证后方可访问,杜绝匿名读。
         var domain = await ResolveDomainAsync();
         var list = await _db.Ado.SqlQueryAsync<PendingInspectionSupplierOption>(
             """

+ 34 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/StockQueryService.cs

@@ -15,19 +15,37 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// 库位·物料·批次存在多货架时生效;当前 MissedPrint 为 0 行,其效果为零且无数据可验,本服务不复刻该拆分
 /// (LocCount≤1 场景即 base + 富集,与现数据一致)。MissedPrint 回补后如需多货架拆分,另起批次复验。
 ///
-/// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转;LocationDetail 无 tenant_id,不做租户过滤。
+/// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转。
+/// 安全:LocationMaster / GeneralizedCodeMaster 含 tenant_id,按当前租户强制过滤;MissedPrint 无 tenant 列,
+/// 由已按租户约束的父键(tenant-safe 的 LocationMaster/LocationDetail 基础层)经关联键约束,不单独写 MissedPrint.tenant_id。
+/// 租户只来自认证后可信 JWT(严格解析),忽略前端 tenantId,杜绝匿名/跨租户可见。
 /// </summary>
 [ApiDescriptionSettings(Order = 300, Description = "库存查询")]
 [Route("api/S5StockQuery")]
-[AllowAnonymous]
 [NonUnify]
 public class StockQueryService : IDynamicApiController, ITransient
 {
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
     private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
 
-    public StockQueryService(ISqlSugarClient db)
+    public StockQueryService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
+        _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;
     }
 
     private sealed class GrandTotals
@@ -61,14 +79,18 @@ public class StockQueryService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
+        var tenantId = ResolveTenantOrThrow();
         var domain = await ResolveDomainAsync();
 
+        // 强制租户过滤:LocationMaster(t) 含 tenant_id,基础层按可信 JWT 租户过滤;count/grand/list 共用同一基础层同 tid。
+        // LocationDetail(d)/ItemMaster(i)/MissedPrint(mp) 均由已按租户约束的 t 经关联键约束(tenant-safe 父键)。
         var where = new List<string>
         {
             "t.Domain = @Domain",
+            "t.tenant_id = @TenantId",
             "(IFNULL(d.AvailStatusQty,0)+IFNULL(d.Assay,0)+IFNULL(d.FreezeQty,0)) > 0",
         };
-        var pars = new List<SugarParameter> { new("@Domain", domain) };
+        var pars = new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.Type))
         {
@@ -188,15 +210,17 @@ public class StockQueryService : IDynamicApiController, ITransient
     [HttpGet("type-options")]
     public async Task<object> GetTypeOptions()
     {
+        var tenantId = ResolveTenantOrThrow();
         var domain = await ResolveDomainAsync();
+        // LocationMaster 含 tenant_id(797:80 / 824:34),强制按可信租户过滤。
         var list = await _db.Ado.SqlQueryAsync<StockQueryTypeOption>(
             $"""
             SELECT DISTINCT {LocTypeExpr.Replace("t.typed", "typed")} AS Val
             FROM LocationMaster
-            WHERE Domain = @Domain AND {LocTypeExpr.Replace("t.typed", "typed")} <> ''
+            WHERE Domain = @Domain AND tenant_id = @TenantId AND {LocTypeExpr.Replace("t.typed", "typed")} <> ''
             ORDER BY Val
             """,
-            new List<SugarParameter> { new("@Domain", domain) });
+            new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) });
         return list;
     }
 
@@ -207,15 +231,17 @@ public class StockQueryService : IDynamicApiController, ITransient
     [HttpGet("location-options")]
     public async Task<object> GetLocationOptions()
     {
+        var tenantId = ResolveTenantOrThrow();
         var domain = await ResolveDomainAsync();
+        // LocationMaster 含 tenant_id(797:80 / 824:34),强制按可信租户过滤。
         var list = await _db.Ado.SqlQueryAsync<StockQueryLocationOption>(
             """
             SELECT location AS Val, descr AS Label
             FROM LocationMaster
-            WHERE Domain = @Domain AND IFNULL(location,'') <> ''
+            WHERE Domain = @Domain AND tenant_id = @TenantId AND IFNULL(location,'') <> ''
             ORDER BY location
             """,
-            new List<SugarParameter> { new("@Domain", domain) });
+            new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) });
         return list;
     }
 }