Bladeren bron

fix(s5): close inventory-data page scope defects (008/009/013)

S5「库存数据」菜单横向审计收口,处理 008 / 009 / 013 三个页面。

【P0】008 标签查询、013 盘点结果查询可被完全未认证调用
  两个 Service 类级带 [AllowAnonymous]。实测匿名请求(无 Authorization 头、
  credentials:'omit' 连 cookie 也不发):
    GET /api/S5LabelQuery/list        → HTTP 200 + total=152 真实业务数据
    GET /api/S5LabelQuery/location-options → HTTP 200
    GET /api/S5StocktakeResult/list   → HTTP 200
  对照:009 与 010 返回 401(009 的注释显示其 [AllowAnonymous] 早已被移除)。
  已去掉两处 [AllowAnonymous],并补上与 009/010/011 同款的严格租户解析
  (只认 JWT、超管未选租户即拒、无默认回退)。修复后实测三个端点均返回 401。

【P1】008 分页被码表 JOIN 放大
  原 JOIN 只按 (FldName='BarcodeStatus', Val) 关联,不带 Domain/租户。
  BarcodeStatus 在 797(8010) 与 824(2410) 各有一套同名码值(16 个码值逐一重复),
  每行都命中 2 条 → SELECT 行数翻倍,而 COUNT 仍按基表算。
  实测 base=152 / oldJoin=304;API 侧 total=152 但 pageSize=200 返回 200 行。
  改为按本仓既定规则双限定 Domain + tenant_id(先例:
  StdInventoryTransactionReader.cs:132 / OrderDeliveryService.cs:634 /
  InoutQueryService.cs:140),并在 ORDER BY 末尾补主键 RecID 唯一 tiebreaker
  (原排序列均非唯一,OFFSET 分页会跨页重复/漏行)。
  修复后 pageSize 10/50/100 三档均 total=fetched=unique=152,连续 3 轮结果一致。

【P1】008 状态文案跨租户混入
  同一 JOIN 缺陷导致 UAT 显示的状态名取自 797 或 824 的字典。
  status-options 也用无租户条件 + GROUP BY Val,Comments,等于任取一份他租户文案。
  两处均改为按当前租户取码表。实测 UAT 自有 BarcodeStatus 字典 0 条
  (其在 GeneralizedCodeMaster 中完全没有行),故状态描述由既有
  COALESCE(g.Comments, m.Status) 回落显示原始码,不借用他租户文本。

【P2】009 / 013 Domain 解析各自实现且无租户条件
  两处都自带
    SELECT Domain FROM GeneralizedCodeMaster
    WHERE FldName='SystemConfig' AND Val='CompanyCode' ORDER BY RecID LIMIT 1
  取全库第一条并硬回落 "8010" —— 与 SourceDomainTenantResolver 修复前同源缺陷。
  UAT 目前碰巧解析正确(全局第一条恰是 797 的 8010),但机制是错的。
  两处统一改调 SourceDomainTenantResolver.ResolveDomainAsync(sourceCode, tenantId),
  解析失败即 fail closed,不再有第三/第四套 Domain 解析实现。
  实测 UAT→8010、797→8010、824→2410;旧写法对 824 会错解析为 8010。

【刻意不做】008 的业务行租户边界
  MissedPrint 的 tenant_id 列存在但实测 152 行全为 NULL,且 150 行 Location 为空。
  按 tenant_id 过滤会把页面整体查空,拿 Location 当边界会误删绝大多数标签。
  因此不伪造租户边界,保留为 DATA-MODEL RISK,并加了一个守卫用例:
  一旦 MissedPrint 出现非 NULL tenant_id 即失败,提醒复核该决策。

测试:新增 12 个实库集成用例(AIDOP_IT=1,全程只读),含旧写法负向对照
(oldJoin 必须 > base,否则用例失去意义)。实测 12/12 通过。
回归:非集成 1141 passed / 0 failed;集成模式失败集合与基线一致的 11 个既有失败
(另有 InventoryConcurrencyTests.CaseD 在全量并发下 flaky,单独跑 4/4 通过,
且对本批改动零引用)。FUNC-S5-010 STD/LIVE 未受影响。
YY968XX 9 uur geleden
bovenliggende
commit
e12bd8c23a

+ 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.439</AssemblyVersion>
-    <FileVersion>1.0.439</FileVersion>
-    <Version>1.0.439</Version>
+    <AssemblyVersion>1.0.440</AssemblyVersion>
+    <FileVersion>1.0.440</FileVersion>
+    <Version>1.0.440</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 228 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryDataPagesScopeTests.cs

@@ -0,0 +1,228 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
+
+/// <summary>
+/// S5「库存数据」菜单 008 / 009 / 013 收口测试。
+/// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读,不写任何表。
+/// </summary>
+[Trait("Category", "Integration")]
+public class InventoryDataPagesScopeTests
+{
+    private const long UatTenant = 838257186181189L;
+    private const long AidopTenant = 797403760988229L;
+    private const long SecondDomainTenant = 824585161322565L;
+    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
+
+    private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
+
+    private readonly Xunit.Abstractions.ITestOutputHelper _out;
+    public InventoryDataPagesScopeTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
+
+    private static ISqlSugarClient BuildAidopdev()
+    {
+        const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
+        if (!File.Exists(path)) return null;
+        var cs = File.ReadLines(path)
+            .Select(l => l.Trim())
+            .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
+            .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
+            .FirstOrDefault(v => !string.IsNullOrEmpty(v));
+        if (string.IsNullOrEmpty(cs)) return null;
+        return new SqlSugarScope(new ConnectionConfig
+        {
+            ConfigId = "aidopdev-invpages-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
+        });
+    }
+
+    // ===================== FUNC-S5-008 标签查询 =====================
+
+    /// <summary>
+    /// 缺陷复现 + 修复验证:码表 JOIN 若不按 Domain+租户限定,会把行数放大。
+    /// 这是本批 008 的核心断言。
+    /// </summary>
+    [Fact]
+    public async Task F008_StatusDictionaryJoin_MustNotAmplifyRowCount()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var baseRows = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint m");
+
+        // 旧写法:只按 (FldName, Val) 关联 —— 负向对照,证明缺陷真实存在
+        var oldJoin = await db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM MissedPrint m
+            LEFT JOIN GeneralizedCodeMaster g ON g.FldName='BarcodeStatus' AND g.Val=m.Status
+            """);
+
+        // 新写法:Domain + 租户双限定
+        var newJoin = await db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM MissedPrint m
+            LEFT JOIN GeneralizedCodeMaster g
+                   ON g.FldName='BarcodeStatus' AND g.Val=m.Status
+                  AND g.Domain=m.Domain AND g.tenant_id=@TenantId
+            """,
+            new List<SugarParameter> { new("@TenantId", UatTenant) });
+
+        _out.WriteLine($"base={baseRows} oldJoin={oldJoin} newJoin={newJoin}");
+        Assert.True(oldJoin > baseRows, "旧写法应当放大行数,否则本用例失去意义");
+        Assert.Equal(baseRows, newJoin);   // 修复后必须与基表行数一致
+    }
+
+    [Theory]
+    [InlineData(UatTenant)]
+    [InlineData(AidopTenant)]
+    [InlineData(SecondDomainTenant)]
+    public async Task F008_StatusDictionaryJoin_YieldsAtMostOneRowPerLabel(long tenantId)
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        // 任一标签行经码表 JOIN 后不得命中多于 1 条字典
+        var worst = await db.Ado.GetIntAsync(
+            """
+            SELECT IFNULL(MAX(c),0) FROM (
+              SELECT m.RecID, COUNT(g.RecID) c
+              FROM MissedPrint m
+              LEFT JOIN GeneralizedCodeMaster g
+                     ON g.FldName='BarcodeStatus' AND g.Val=m.Status
+                    AND g.Domain=m.Domain AND g.tenant_id=@TenantId
+              GROUP BY m.RecID
+            ) x
+            """,
+            new List<SugarParameter> { new("@TenantId", tenantId) });
+
+        _out.WriteLine($"tenant={tenantId} 单行最多命中字典条数={worst}");
+        Assert.True(worst <= 1, $"tenant={tenantId} 存在一行命中 {worst} 条字典,会造成分页放大");
+    }
+
+    /// <summary>状态文案不得来自其它租户:当前租户无字典时应为空(由服务层 COALESCE 回落原始码)。</summary>
+    [Fact]
+    public async Task F008_StatusText_NeverBorrowedFromAnotherTenant()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var uatOwnDict = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM GeneralizedCodeMaster WHERE FldName='BarcodeStatus' AND tenant_id=@T",
+            new List<SugarParameter> { new("@T", UatTenant) });
+
+        var borrowed = await db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM MissedPrint m
+            JOIN GeneralizedCodeMaster g
+              ON g.FldName='BarcodeStatus' AND g.Val=m.Status
+             AND g.Domain=m.Domain AND g.tenant_id=@TenantId
+            WHERE g.tenant_id <> @TenantId
+            """,
+            new List<SugarParameter> { new("@TenantId", UatTenant) });
+
+        _out.WriteLine($"UAT 自有 BarcodeStatus 字典条数={uatOwnDict};借用他租户文案的行数={borrowed}");
+        Assert.Equal(0, borrowed);
+    }
+
+    /// <summary>ORDER BY 的 tiebreaker 必须唯一:MissedPrint 主键为 RecID。</summary>
+    [Fact]
+    public async Task F008_RecIdIsUniqueTiebreaker()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var total = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
+        var distinct = await db.Ado.GetIntAsync("SELECT COUNT(DISTINCT RecID) FROM MissedPrint");
+        _out.WriteLine($"MissedPrint rows={total} distinct RecID={distinct}");
+        Assert.Equal(total, distinct);
+    }
+
+    /// <summary>
+    /// 数据模型债的实证守卫:一旦 MissedPrint 开始有非 NULL 的 tenant_id,
+    /// 说明可以建立真正的租户边界,此用例会失败以提醒复核 008 的 Scope 决策。
+    /// </summary>
+    [Fact]
+    public async Task F008_MissedPrintTenantColumn_StillUnusable_DataModelDebtGuard()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var total = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
+        var withTenant = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id IS NOT NULL");
+        var withLocation = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE IFNULL(Location,'')<>''");
+
+        _out.WriteLine($"MissedPrint rows={total} tenant_id 非空={withTenant} Location 非空={withLocation}");
+        Assert.Equal(0, withTenant);   // 仍全为 NULL → 无法建立租户边界,008 的 DATA-MODEL RISK 依然成立
+    }
+
+    // ===================== FUNC-S5-009 / 013 Domain 解析 =====================
+
+    [Theory]
+    [InlineData(UatTenant, "8010")]
+    [InlineData(AidopTenant, "8010")]
+    [InlineData(SecondDomainTenant, "2410")]
+    public async Task F009_F013_DomainResolvedPerTenant(long tenantId, string expected)
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var domain = await new SourceDomainTenantResolver(db).ResolveDomainAsync(SourceCode, tenantId);
+        _out.WriteLine($"tenant={tenantId} → domain={domain}");
+        Assert.Equal(expected, domain);
+    }
+
+    /// <summary>
+    /// 负向对照:旧的「全局取第一条 CompanyCode」写法对所有租户返回同一个 Domain,
+    /// 与按租户解析的结果不一致 —— 证明 009/013 换用统一 resolver 是必要的。
+    /// </summary>
+    [Fact]
+    public async Task F009_F013_LegacyGlobalFirstDomain_DiffersFromPerTenantResolution()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var legacy = (await db.Ado.SqlQueryAsync<string>(
+            "SELECT Domain FROM GeneralizedCodeMaster WHERE FldName='SystemConfig' AND Val='CompanyCode' ORDER BY RecID LIMIT 1"))
+            .FirstOrDefault();
+
+        var resolver = new SourceDomainTenantResolver(db);
+        var second = await resolver.ResolveDomainAsync(SourceCode, SecondDomainTenant);
+
+        _out.WriteLine($"旧写法(全局第一条)={legacy};按租户解析 {SecondDomainTenant} → {second}");
+        Assert.NotEqual(legacy, second);   // 旧写法会把 824 错解析成 8010
+    }
+
+    [Fact]
+    public async Task F009_F013_TenantWithoutDomainMapping_FailsClosed()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var orphan = (await db.Ado.SqlQueryAsync<long>(
+            """
+            SELECT t.Id FROM SysTenant t
+            WHERE t.Status = 1
+              AND NOT EXISTS (SELECT 1 FROM GeneralizedCodeMaster g
+                              WHERE g.tenant_id=t.Id AND g.FldName='SystemConfig' AND g.Val='CompanyCode'
+                                AND IFNULL(g.Domain,'')<>'')
+              AND NOT EXISTS (SELECT 1 FROM LocationMaster l
+                              WHERE l.tenant_id=t.Id AND IFNULL(l.Domain,'')<>'')
+            ORDER BY t.Id LIMIT 1
+            """)).FirstOrDefault();
+        Assert.True(orphan > 0, "需要一个无 Domain 映射的启用租户来验证 fail closed");
+
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
+            () => new SourceDomainTenantResolver(db).ResolveDomainAsync(SourceCode, orphan));
+        Assert.DoesNotContain("8010", ex.Message);   // 不得悄悄回落固定 Domain
+    }
+}

+ 50 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/LabelQueryService.cs

@@ -9,19 +9,40 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// 参考本仓既有同表只读投影先例:Controllers/S0/Manufacturing/AdoS0MfgProcessFlowCardsController(同解 BarcodeStatus)。
 ///
 /// 本服务仅 SELECT:无新增/编辑/删除/库位转移/库存事务/状态流转/物料卡写入;扁平列表,无 detail。
-/// MissedPrint 无 tenant_id 列,不做租户/工厂域过滤(对齐既有 MissedPrint 只读投影先例;按工厂域隔离为后续独立议题)。
+///
+/// 【租户边界的现状,勿误读】MissedPrint 的 tenant_id 列存在但实测**全为 NULL**,
+/// 且 152 行中 150 行 Location 为空 —— 既不能按 tenant_id 过滤(会把页面整体查空),
+/// 也不能拿 Location 当租户边界(会误删绝大多数合法标签)。
+/// 故业务行的租户归属仍是**未解决的数据模型债**,本服务不伪造租户边界;
+/// 已做的收敛是:① 类级去 [AllowAnonymous](原先可被完全未认证调用,实测匿名 GET 返回 200 + 152 行真实数据);
+/// ② 状态码表 JOIN 按 Domain + 当前租户限定(见下)。
 /// </summary>
 [ApiDescriptionSettings(Order = 302, Description = "标签查询")]
 [Route("api/S5LabelQuery")]
-[AllowAnonymous]
 [NonUnify]
 public class LabelQueryService : IDynamicApiController, ITransient
 {
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
     private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
 
-    public LabelQueryService(ISqlSugarClient db)
+    public LabelQueryService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
+        _userManager = userManager;
+    }
+
+    /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token 或超管未选目标租户 → 拒绝,无默认回退。</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>
@@ -35,8 +56,11 @@ public class LabelQueryService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
+        // 仅用于状态码表 JOIN 的租户限定;业务行本身的租户边界见类注释(数据模型债,本批不伪造)
+        var tenantId = ResolveTenantOrThrow();
+
         var where = new List<string> { "1=1" };
-        var pars = new List<SugarParameter>();
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.ItemNum))
         {
@@ -111,9 +135,21 @@ public class LabelQueryService : IDynamicApiController, ITransient
                 m.ExpireDate  AS ExpireDate,
                 m.Remark      AS Remark
             FROM MissedPrint m
-            LEFT JOIN GeneralizedCodeMaster g ON g.FldName = 'BarcodeStatus' AND g.Val = m.Status
+            -- 码表 JOIN 必须按 Domain + 租户双限定(本仓既定规则,见
+            -- StdInventoryTransactionReader.cs / OrderDeliveryService.cs / InoutQueryService.cs)。
+            -- 原实现只按 (FldName, Val) 关联:BarcodeStatus 在 797(8010) 与 824(2410) 各有一套同名码值,
+            -- 每行都命中 2 条 → 分页 SELECT 行数翻倍(实测 base 152 → 304,而 COUNT 仍按基表算 152),
+            -- 且状态文案会取到**别的租户**的字典。
+            -- 当前租户无该码表时 g 全为 NULL,由既有 COALESCE 回落显示原始状态码,不借用他租户文本。
+            LEFT JOIN GeneralizedCodeMaster g
+                   ON g.FldName = 'BarcodeStatus'
+                  AND g.Val = m.Status
+                  AND g.Domain = m.Domain
+                  AND g.tenant_id = @TenantId
             WHERE {whereSql}
-            ORDER BY {BuildOrderBy(input.OrderBy, input.OrderDir)}
+            -- 业务排序保持不变,末尾补主键 RecID 作唯一 tiebreaker:
+            -- 原排序列均非唯一,OFFSET 分页在并列行上会跨页重复/漏行。
+            ORDER BY {BuildOrderBy(input.OrderBy, input.OrderDir)}, m.RecID
             LIMIT {pageSize} OFFSET {offset}
             """,
             pars);
@@ -128,14 +164,19 @@ public class LabelQueryService : IDynamicApiController, ITransient
     [HttpGet("status-options")]
     public async Task<object> GetStatusOptions()
     {
+        // 与列表 JOIN 同口径:按当前租户取码表。
+        // 原实现无租户条件 + GROUP BY Val,Comments —— 把多个租户的字典揉成一份,
+        // 等于「任取一个别的租户的文案」,与列表侧口径也不一致。
+        var tenantId = ResolveTenantOrThrow();
         var list = await _db.Ado.SqlQueryAsync<LabelQueryStatusOption>(
             """
             SELECT Val AS Val, Comments AS Label
             FROM GeneralizedCodeMaster
-            WHERE FldName = 'BarcodeStatus' AND Comments IS NOT NULL AND Comments <> ''
-            GROUP BY Val, Comments
+            WHERE FldName = 'BarcodeStatus' AND tenant_id = @TenantId
+              AND Comments IS NOT NULL AND Comments <> ''
             ORDER BY Val
-            """);
+            """,
+            new List<SugarParameter> { new("@TenantId", tenantId) });
         return list;
     }
 

+ 31 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PendingInspectionService.cs

@@ -21,13 +21,19 @@ public class PendingInspectionService : IDynamicApiController, ITransient
     /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
     private const long MainTenantId = 1300000000001L;
 
+    /// <summary>贴源源码:与 S5 其余库存页保持一致,供 Domain 解析使用。</summary>
+    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
+
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
+    private readonly SourceDomainTenantResolver _domainTenant;
 
-    public PendingInspectionService(ISqlSugarClient db, UserManager userManager)
+    public PendingInspectionService(
+        ISqlSugarClient db, UserManager userManager, SourceDomainTenantResolver domainTenant)
     {
         _db = db;
         _userManager = userManager;
+        _domainTenant = domainTenant;
     }
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
@@ -41,13 +47,26 @@ public class PendingInspectionService : IDynamicApiController, ITransient
         return tid;
     }
 
-    /// <summary>解析公司域(GeneralizedCodeMaster SystemConfig/CompanyCode,默认 8010)。</summary>
-    private async Task<string> ResolveDomainAsync()
+    /// <summary>
+    /// 按当前租户解析公司域,统一走 <see cref="SourceDomainTenantResolver"/>。
+    /// <para>
+    /// 原实现是 <c>SELECT Domain FROM GeneralizedCodeMaster WHERE FldName='SystemConfig'
+    /// AND Val='CompanyCode' ORDER BY RecID LIMIT 1</c> —— **无 tenant 条件**,取的是全库第一条,
+    /// 与 resolver 修复前的缺陷同源:不同租户会被解析到同一个(别人的)Domain,
+    /// 且解析不到时硬回落 "8010"。本项目的 Domain 解析只允许有一个正式实现。
+    /// </para>
+    /// </summary>
+    private async Task<string> ResolveDomainOrThrowAsync(long tenantId, CancellationToken ct = default)
     {
-        var list = await _db.Ado.SqlQueryAsync<string>(
-            "SELECT Domain FROM GeneralizedCodeMaster WHERE FldName='SystemConfig' AND Val='CompanyCode' ORDER BY RecID LIMIT 1");
-        var domain = list.FirstOrDefault();
-        return string.IsNullOrWhiteSpace(domain) ? "8010" : domain;
+        try
+        {
+            return await _domainTenant.ResolveDomainAsync(SourceCode, tenantId, ct);
+        }
+        catch (Exception ex)
+        {
+            // fail closed:解析不出租户 Domain 就不查,绝不回落固定值
+            throw Oops.Oh($"无法解析当前租户 Domain 映射:{ex.Message}");
+        }
     }
 
     /// <summary>
@@ -61,7 +80,9 @@ public class PendingInspectionService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
-        var domain = await ResolveDomainAsync();
+        // 租户先解析,Domain 由租户决定(不再全局取第一条)
+        var listTenantId = ResolveTenantOrThrow();
+        var domain = await ResolveDomainOrThrowAsync(listTenantId);
 
         var where = new List<string>
         {
@@ -74,7 +95,7 @@ public class PendingInspectionService : IDynamicApiController, ITransient
         // LEFT JOIN 的 qms 申请/明细表已补 tenant_id,租户过滤置于 JOIN ON(避免退化为 INNER JOIN 丢失 MissedPrint 锚行)。
         // 租户改用严格可信解析(仅认证后 JWT,忽略前端 tenantId);配合类级已去 [AllowAnonymous],杜绝匿名跨租户读。
         // 残留边界:MissedPrint 锚行本身仍按 Domain(非租户)可见,此为旧系统 pr_WMS_GetMissedTempQty 无租户列的既有约束。
-        var tenantId = ResolveTenantOrThrow();
+        var tenantId = listTenantId;
         var pars = new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.DemandNo))
@@ -186,7 +207,7 @@ public class PendingInspectionService : IDynamicApiController, ITransient
     {
         // 残留边界(注明):本选项来自 MissedPrint(无 tenant_id)+ SuppMaster(无 tenant_id),无 tenant-safe 父键可挂载租户过滤,
         // 只能按 Domain 公司域收敛,无法做租户行级过滤(既有约束)。类级已去 [AllowAnonymous] → 需认证后方可访问,杜绝匿名读。
-        var domain = await ResolveDomainAsync();
+        var domain = await ResolveDomainOrThrowAsync(ResolveTenantOrThrow());
         var list = await _db.Ado.SqlQueryAsync<PendingInspectionSupplierOption>(
             """
             SELECT DISTINCT m.Supply AS Val, s.SortName AS Label

+ 45 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/StocktakeResultService.cs

@@ -10,30 +10,64 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// 盘点口径由 SQL 内定义(非脑补):
 ///   账面(标签数量)=Qty;实盘数量=CompQty;差异数量=CompQty-Qty;
 ///   盘点状态=CompQty≠Qty?'有差异':'正常';状态=Status='Q'?'冻结':'';实际库位=FirmString2;实际货架=FirmString4。
-/// Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析,默认 8010。排序 UpdateTime DESC, RecID DESC。
+/// Domain 按当前租户经 SourceDomainTenantResolver 解析(不再全局取第一条、无 8010 回落)。排序 UpdateTime DESC, RecID DESC。
 ///
-/// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转;MissedPrint 无 tenant_id,不做租户过滤。
+/// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转。
+/// MissedPrint 的 tenant_id 列存在但实测全为 NULL,无法做租户行级过滤,仅能按 Domain 公司域收敛(既有数据模型约束)。
+/// 类级已去 [AllowAnonymous]:原先该接口可被**完全未认证**调用(实测匿名 GET 返回 200),已收敛为需认证。
 /// </summary>
 [ApiDescriptionSettings(Order = 304, Description = "盘点结果查询")]
 [Route("api/S5StocktakeResult")]
-[AllowAnonymous]
 [NonUnify]
 public class StocktakeResultService : IDynamicApiController, ITransient
 {
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
+    /// <summary>贴源源码:与 S5 其余库存页保持一致,供 Domain 解析使用。</summary>
+    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
+
     private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+    private readonly SourceDomainTenantResolver _domainTenant;
 
-    public StocktakeResultService(ISqlSugarClient db)
+    public StocktakeResultService(
+        ISqlSugarClient db, UserManager userManager, SourceDomainTenantResolver domainTenant)
     {
         _db = db;
+        _userManager = userManager;
+        _domainTenant = domainTenant;
     }
 
-    /// <summary>解析公司域(GeneralizedCodeMaster SystemConfig/CompanyCode,默认 8010)。</summary>
-    private async Task<string> ResolveDomainAsync()
+    /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token 或超管未选目标租户 → 拒绝,无默认回退。</summary>
+    private long ResolveTenantOrThrow()
     {
-        var list = await _db.Ado.SqlQueryAsync<string>(
-            "SELECT Domain FROM GeneralizedCodeMaster WHERE FldName='SystemConfig' AND Val='CompanyCode' ORDER BY RecID LIMIT 1");
-        var domain = list.FirstOrDefault();
-        return string.IsNullOrWhiteSpace(domain) ? "8010" : domain;
+        var tid = _userManager.TenantId;
+        if (tid <= 0)
+            throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户");
+        if (_userManager.SuperAdmin && tid == MainTenantId)
+            throw Oops.Oh("超级管理员操作前必须选择目标租户");
+        return tid;
+    }
+
+    /// <summary>
+    /// 按当前租户解析公司域,统一走 <see cref="SourceDomainTenantResolver"/>。
+    /// <para>
+    /// 原实现是 <c>SELECT Domain FROM GeneralizedCodeMaster WHERE FldName='SystemConfig'
+    /// AND Val='CompanyCode' ORDER BY RecID LIMIT 1</c> —— **无 tenant 条件**,取全库第一条并硬回落 "8010",
+    /// 与 resolver 修复前的缺陷同源。本项目的 Domain 解析只允许有一个正式实现。
+    /// </para>
+    /// </summary>
+    private async Task<string> ResolveDomainOrThrowAsync(long tenantId, CancellationToken ct = default)
+    {
+        try
+        {
+            return await _domainTenant.ResolveDomainAsync(SourceCode, tenantId, ct);
+        }
+        catch (Exception ex)
+        {
+            throw Oops.Oh($"无法解析当前租户 Domain 映射:{ex.Message}");
+        }
     }
 
     /// <summary>
@@ -47,7 +81,7 @@ public class StocktakeResultService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
-        var domain = await ResolveDomainAsync();
+        var domain = await ResolveDomainOrThrowAsync(ResolveTenantOrThrow());
 
         var where = new List<string> { "m.Domain = @Domain", "m.IsTag = 1" };
         var pars = new List<SugarParameter> { new("@Domain", domain) };