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

fix(s0): P0 租户边界收口 — 关闭 25 个 Controller 的匿名越权,tenant_id 成为唯一隔离依据

背景:审计发现 S0 的 52 个 Controller 中有 25 个带类级 [AllowAnonymous] 且零租户解析。
全局 AOP 在 App.User 为空时既不加租户过滤器也不 stamp TenantId(SqlSugarSetup.cs:232/281/302),
因此无任何认证即可跨租户读写 S0 业务数据——实测无 token 可读 22690 条物料 / 3562 供应商 /
1332 客户 / 632 条他租户人员技能,且匿名写入会落 tenant_id = NULL。

收口范围(25 个 Controller + 共享基础设施):
- 删除全部类级 [AllowAnonymous],S0 业务接口一律要求认证;未经批准的匿名接口 = 0。
- 每个 action 先经 AdoS0TenantScope.TryResolveRequired 解析可信租户(只认 JWT claim,
  拒绝前端入参、拒绝默认租户兜底、拒绝超管未选租户),失败即返回明确业务错误。
- List:一律 tenant-bound 后再叠加 Domain/Code/Status 等业务筛选。
- Detail/Update/Delete:按 (Id + TenantId) 取数,越租户与不存在同样返回 NotFound,不泄露存在性。
- Create:显式 entity.TenantId = tenantId,不信任 DTO,不把全局 AOP 当唯一 stamp 机制。
- Lookup/Options/Export 等非 CRUD 入口同样收口(含 7 处表达式体 action 改块体)。
- 唯一性预检 IsAnyAsync 补 tenant 条件,杜绝跨租户误判重复。

新增 Infrastructure/AdoS0TenantQuery.cs:
  ScopedTo / ByIdScopedAsync(ITenantIdFilter 实体)
  ScopedToByColumn / ByIdScopedByColumnAsync(qms_* 系列,未实现 ITenantIdFilter、
  全局 AOP 对其从不生效,必须按物理列 tenant_id 显式收口)

AdoS0ReferenceChecker:12 个父对象校验/删除保护方法补 long tenantId 首参与显式租户条件,
消除"只按 ItemNum / Supp / Location / SkillNo 查全库"的形态(§十四)。

Domain 口径:本轮不做 8010→2410 归一、不改任何历史 Domain 值;只确保没有任何 Controller
把 Domain 当唯一安全过滤。tenant_id 是唯一租户安全边界。

防回归:新增 S0ControllerTenantClosureTests(209 条),CI 级守住四条铁律——
类级匿名、逐 action 租户解析、Domain 不得替代 tenant、Create 必须显式 stamp;
静态接口豁免按 Controller.Action 精确登记并写明理由。

验证:dotnet build net10.0 后端 0 Error;插件测试 888 通过 / 2 跳过 / 0 失败;
基于最新 master 启动正常(1.0.391,无待执行 migration);
匿名 GET 扫描 63 条 S0 路由 200 泄露 = 0;匿名 POST 13 个写入口全 401 且 DB 计数零变化;
双租户(AIDOPDemo 797403760988229 / UATAdminA 838257186181189)隔离验证:他租户数据全部
total=0,跨租户 GET/PUT/DELETE 全部 404 且目标行零改动;Domain='2410' 横跨 4 租户各 7 行,
本租户查询只得 7 行(非 28)证明 Domain 已不再越权;正常功能与 Batch1-13 数据无回归。

无 schema 变更:涉及的 45 张表均已有 tenant_id,故不新增 migration;未补任何演示数据。

chore: bump version server 1.0.391
YY968XX 15 часов назад
Родитель
Сommit
e39598579d
30 измененных файлов с 775 добавлено и 340 удалено
  1. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  2. 154 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Tenant/S0ControllerTenantClosureTests.cs
  3. 10 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgElementParamsController.cs
  4. 14 9
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgLineMaterialsController.cs
  5. 20 14
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgLinePostsController.cs
  6. 10 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgPersonSkillAssignmentsController.cs
  7. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgPersonSkillsController.cs
  8. 10 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgPreprocessElementParamsController.cs
  9. 13 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgPreprocessElementsController.cs
  10. 17 11
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgProductionLinesController.cs
  11. 17 10
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgRoutingOpDetailsController.cs
  12. 10 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgSopDocumentsController.cs
  13. 15 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgSopFileTypesController.cs
  14. 12 7
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgStandardOperationsController.cs
  15. 15 10
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgWorkCentersController.cs
  16. 29 21
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0ProductStructuresController.cs
  17. 5 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0WorkOrdControlController.cs
  18. 15 7
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QmsSpecImportController.cs
  19. 81 58
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityAggregateControllers.cs
  20. 144 52
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityBaseDataControllers.cs
  21. 0 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityDictionaryController.cs
  22. 26 20
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityFqcOqcControllers.cs
  23. 14 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0CustomersController.cs
  24. 0 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0KittingExcludeRuleController.cs
  25. 16 7
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0MaterialsController.cs
  26. 3 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0SubstituteSchemesController.cs
  27. 23 17
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SrmPurchasesController.cs
  28. 19 13
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SuppliersController.cs
  29. 36 33
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0ReferenceChecker.cs
  30. 43 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0TenantQuery.cs

+ 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.390</AssemblyVersion>
-    <FileVersion>1.0.390</FileVersion>
-    <Version>1.0.390</Version>
+    <AssemblyVersion>1.0.391</AssemblyVersion>
+    <FileVersion>1.0.391</FileVersion>
+    <Version>1.0.391</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 154 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Tenant/S0ControllerTenantClosureTests.cs

@@ -0,0 +1,154 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S0.Tenant;
+
+/// <summary>
+/// S0-P0-TENANT-ID-CLOSURE 防回归契约。
+///
+/// 守住四条铁律,任一被破坏则 CI 直接失败:
+///  1. S0 业务 Controller 不得带类级 [AllowAnonymous](白名单外一律失败);
+///  2. 每个 S0 业务 action 必须显式解析租户(AdoS0TenantScope.TryResolveRequired);
+///  3. 不得只用 Domain / DomainCode 做隔离而没有任何 tenant 条件;
+///  4. 有 Create 的 Controller 必须显式 stamp 租户,不得依赖全局 AOP。
+///
+/// 用源码静态扫描而非反射执行,避免测试需要真实 DB / HTTP 上下文。
+/// </summary>
+public class S0ControllerTenantClosureTests
+{
+    /// <summary>
+    /// 允许匿名的 S0 接口白名单(必须写明业务理由)。
+    /// 当前为空:S0 全部业务接口都要求认证。
+    /// 新增条目前必须在评审中给出明确业务理由,否则本测试即为拦截点。
+    /// </summary>
+    private static readonly HashSet<string> IntentionalAnonymousWhitelist = new(StringComparer.Ordinal)
+    {
+        // 例:("AdoS0XxxController", "对外健康探活,不返回任何业务数据")
+    };
+
+    /// <summary>
+    /// 无数据库访问、因而不需要租户解析的 action 白名单(按 Controller 文件名.Action 精确登记)。
+    /// 仅允许纯静态字典 / 静态模板;任何触达仓储的 action 都不得登记在此。
+    /// </summary>
+    private static readonly HashSet<string> NoDataAccessActions = new(StringComparer.Ordinal)
+    {
+        "AdoS0QualityDictionaryController.GetNodes",        // 写死的前端菜单节点数组
+        "AdoS0EmpWorkDutiesController.GetDutyOptions",      // 代码内静态 DutyMap 枚举
+        "AdoS0QmsSpecImportController.RawTemplate",         // 静态 Excel 导入模板
+        "AdoS0QmsSpecImportController.ProcessTemplate",     // 静态 Excel 导入模板
+        "AdoS0QmsSpecImportController.FqcTemplate",         // 静态 Excel 导入模板
+        "AdoS0QmsSpecImportController.OqcTemplate",         // 静态 Excel 导入模板
+    };
+
+    private static string ControllersRoot()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        return Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0");
+    }
+
+    public static IEnumerable<object[]> S0ControllerFiles()
+    {
+        foreach (var f in Directory.EnumerateFiles(ControllersRoot(), "*.cs", SearchOption.AllDirectories).OrderBy(x => x))
+            yield return [Path.GetFileNameWithoutExtension(f), f];
+    }
+
+    /// <summary>类级 [AllowAnonymous]:白名单以外一律禁止。</summary>
+    [Theory]
+    [MemberData(nameof(S0ControllerFiles))]
+    public void S0Controllers_MustNotBeAnonymous(string name, string path)
+    {
+        var src = File.ReadAllText(path);
+        foreach (var cls in ClassLevelAnonymousClasses(src))
+        {
+            Assert.True(IntentionalAnonymousWhitelist.Contains(cls),
+                $"{name}: 类 {cls} 带类级 [AllowAnonymous],S0 业务接口必须认证;"
+                + "确需匿名请先在 IntentionalAnonymousWhitelist 登记并写明业务理由。");
+        }
+    }
+
+    /// <summary>每个 action 必须显式解析租户;不接受仅靠全局 AOP。</summary>
+    [Theory]
+    [MemberData(nameof(S0ControllerFiles))]
+    public void S0Controllers_EveryActionResolvesTenant(string name, string path)
+    {
+        var src = File.ReadAllText(path);
+
+        // action = [Http*] 特性之后最近的方法名;扣除显式登记的无数据库访问 action
+        var actionNames = Regex.Matches(
+                src,
+                @"\[Http(?:Get|Post|Put|Delete|Patch)[^\]]*\][\s\S]{0,400}?\b(?:public|internal)\s+(?:async\s+)?(?:Task<[^>]+>|IActionResult|Task)\s+(\w+)\s*\(")
+            .Select(m => m.Groups[1].Value)
+            .ToList();
+
+        var exempt = actionNames.Count(a => NoDataAccessActions.Contains($"{name}.{a}"));
+        var required = Regex.Matches(src, @"\[Http(?:Get|Post|Put|Delete|Patch)").Count - exempt;
+        if (required <= 0) return;
+
+        var guards = Regex.Matches(src, @"AdoS0TenantScope\.TryResolveRequired").Count;
+        Assert.True(guards >= required,
+            $"{name}: 需解析租户的 action 数={required}(已豁免 {exempt} 个静态接口)但 "
+            + $"TryResolveRequired 次数={guards};每个 S0 业务 action 都必须显式解析租户后再访问数据。");
+    }
+
+    /// <summary>Domain / DomainCode 不得替代 tenant_id 承担隔离职责。</summary>
+    [Theory]
+    [MemberData(nameof(S0ControllerFiles))]
+    public void S0Controllers_DomainMustNotReplaceTenant(string name, string path)
+    {
+        var src = File.ReadAllText(path);
+        var domain = Regex.Matches(src, @"\.Domain\s*==|DomainCode\s*==").Count;
+        if (domain == 0) return;
+
+        var tenant = Regex.Matches(src, @"TenantId\s*==|ScopedTo\(|ScopedToByColumn\(|ByIdScoped").Count;
+        Assert.True(tenant > 0,
+            $"{name}: 出现 Domain/DomainCode 过滤但没有任何 tenant 条件;"
+            + "tenant_id 是唯一租户安全边界,Domain 只能作为业务筛选。");
+    }
+
+    /// <summary>有写入的 Controller 必须显式 stamp 租户。</summary>
+    [Theory]
+    [MemberData(nameof(S0ControllerFiles))]
+    public void S0Controllers_CreateMustStampTenant(string name, string path)
+    {
+        var src = File.ReadAllText(path);
+        if (!src.Contains("[HttpPost]")) return;
+        // 纯导入/模板类接口不落主表实体,由各自 factory 内部 stamp
+        var stamps = Regex.Matches(src, @"TenantId\s*=\s*(tenantId|tid)\b").Count;
+        Assert.True(stamps > 0,
+            $"{name}: 存在 [HttpPost] 但未见任何显式 TenantId 赋值;"
+            + "Create 的租户必须由后端可信上下文赋值,不得依赖全局 AOP 兜底。");
+    }
+
+    /// <summary>可信租户解析器本身不得被前端入参污染。</summary>
+    [Fact]
+    public void TenantScope_MustNotAcceptClientSuppliedTenant()
+    {
+        // ControllersRoot() = <plugin>/Controllers/S0 → 上溯两级取插件根
+        var root = new DirectoryInfo(ControllersRoot()).Parent!.Parent!.FullName;
+        var scope = Path.Combine(root, "Infrastructure", "AdoS0TenantScope.cs");
+        Assert.True(File.Exists(scope), "AdoS0TenantScope.cs 不存在");
+
+        var src = File.ReadAllText(scope);
+        Assert.Contains("um.TenantId", src);
+        Assert.DoesNotContain("FromQuery", src);
+        Assert.DoesNotContain("FromBody", src);
+    }
+
+    private static IEnumerable<string> ClassLevelAnonymousClasses(string src)
+    {
+        foreach (Match m in Regex.Matches(src, @"^[ \t]*\[AllowAnonymous\][ \t]*\r?$", RegexOptions.Multiline))
+        {
+            var tail = src[m.Index..];
+            var cls = Regex.Match(tail, @"public\s+(?:sealed\s+)?class\s+(\w+)");
+            var http = tail.IndexOf("[Http", StringComparison.Ordinal);
+            if (cls.Success && (http == -1 || cls.Index < http))
+                yield return cls.Groups[1].Value;
+        }
+    }
+}

+ 10 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgElementParamsController.cs

@@ -7,7 +7,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
 [ApiController]
 [Route("api/s0/manufacturing/element-params")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgElementParamsController : ControllerBase
 {
@@ -21,12 +20,13 @@ public class AdoS0MfgElementParamsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0DevMonitorQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.Page;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
         // 去 domain 化(S0-DOMAIN-FILTER-REMOVAL-READONLY-1):不再按 Domain 过滤,租户隔离由实体 ITenantIdFilter 自动生效;保留 CodeType 业务条件。
-        var query = _rep.AsQueryable()
+        var query = _rep.ScopedTo(tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
                 x => x.DeviceNumber.Contains(q.Keyword!)
                     || (x.DeviceDescr != null && x.DeviceDescr.Contains(q.Keyword!))
@@ -47,16 +47,19 @@ public class AdoS0MfgElementParamsController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0DevMonitorUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var now = DateTime.Now;
         var entity = new AdoS0DevMonitor
         {
+            TenantId = tenantId,
             Domain = dto.Domain.Trim(),
             CodeType = "Prod",
             DeviceNumber = dto.DeviceNumber.Trim(),
@@ -81,7 +84,8 @@ public class AdoS0MfgElementParamsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0DevMonitorUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         entity.Domain = dto.Domain.Trim();
@@ -106,7 +110,8 @@ public class AdoS0MfgElementParamsController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
         await _rep.DeleteAsync(item);

+ 14 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgLineMaterialsController.cs

@@ -7,7 +7,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
 [ApiController]
 [Route("api/s0/manufacturing/line-materials")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgLineMaterialsController : ControllerBase
 {
@@ -28,13 +27,14 @@ public class AdoS0MfgLineMaterialsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0ProdLineDetailQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.EffectivePage;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
         var hasKeywordOp = int.TryParse(q.Keyword, out var keywordOp);
 
         // 源平台 where IsActive=1;这里默认仅返回启用记录
-        var query = _rep.AsQueryable()
+        var query = _rep.ScopedTo(tenantId)
             .Where(x => x.IsActive)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Domain), x => x.Domain == q.Domain)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Part), x => x.Part.Contains(q.Part!))
@@ -60,7 +60,7 @@ public class AdoS0MfgLineMaterialsController : ControllerBase
         var lineDescribeMap = new Dictionary<string, string>();
         if (!string.IsNullOrWhiteSpace(domain) && lineCodes.Count > 0)
         {
-            var lines = await _lineMasterRep.AsQueryable()
+            var lines = await _lineMasterRep.ScopedTo(tenantId)
                 .Where(x => x.Domain == domain && lineCodes.Contains(x.Line))
                 .ToListAsync();
             lineDescribeMap = lines.ToDictionary(x => x.Line, x => x.Describe ?? string.Empty);
@@ -69,7 +69,7 @@ public class AdoS0MfgLineMaterialsController : ControllerBase
         var workCtrDescrMap = new Dictionary<string, string>();
         if (!string.IsNullOrWhiteSpace(domain) && workCtrCodes.Count > 0)
         {
-            var ctrs = await _workCtrRep.AsQueryable()
+            var ctrs = await _workCtrRep.ScopedTo(tenantId)
                 .Where(x => x.Domain == domain && workCtrCodes.Contains(x.WorkCtr))
                 .ToListAsync();
             workCtrDescrMap = ctrs.ToDictionary(x => x.WorkCtr, x => x.Descr ?? string.Empty);
@@ -116,21 +116,24 @@ public class AdoS0MfgLineMaterialsController : ControllerBase
     [HttpGet("{id:int}")]
     public async Task<IActionResult> GetAsync(int id)
     {
-        var item = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var item = await _rep.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ProdLineDetailUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var part = dto.Part.Trim();
         var domain = dto.Domain.Trim();
-        if (await _rep.IsAnyAsync(x => x.Domain == domain && x.Part == part && x.Op == dto.Op && x.Line == dto.Line))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Domain == domain && x.Part == part && x.Op == dto.Op && x.Line == dto.Line))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该生产线物料配置已存在");
 
         var now = DateTime.Now;
         var entity = new AdoS0ProdLineDetail
         {
+            TenantId = tenantId,
             Part = dto.Part.Trim(),
             Op = dto.Op,
             Site = dto.Site,
@@ -157,12 +160,13 @@ public class AdoS0MfgLineMaterialsController : ControllerBase
     [HttpPut("{id:int}")]
     public async Task<IActionResult> UpdateAsync(int id, [FromBody] AdoS0ProdLineDetailUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         var part = dto.Part.Trim();
         var domain = dto.Domain.Trim();
-        if (await _rep.IsAnyAsync(x => x.Id != id && x.Domain == domain && x.Part == part && x.Op == dto.Op && x.Line == dto.Line))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.Domain == domain && x.Part == part && x.Op == dto.Op && x.Line == dto.Line))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该生产线物料配置已存在");
 
         entity.Part = dto.Part.Trim();
@@ -188,7 +192,8 @@ public class AdoS0MfgLineMaterialsController : ControllerBase
     [HttpDelete("{id:int}")]
     public async Task<IActionResult> DeleteAsync(int id)
     {
-        var item = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var item = await _rep.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });

+ 20 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgLinePostsController.cs

@@ -7,7 +7,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
 [ApiController]
 [Route("api/s0/manufacturing/line-posts")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgLinePostsController : ControllerBase
 {
@@ -31,12 +30,13 @@ public class AdoS0MfgLinePostsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0LineSkillMasterQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.Page;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
         var domain = q.Domain?.Trim();
-        var query = _postRep.AsQueryable()
+        var query = _postRep.ScopedTo(tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(domain), x => x.Domain == domain)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
                 x => x.ProdLine.Contains(q.Keyword!) || x.JOBNo.Contains(q.Keyword!) || (x.JOBDescr != null && x.JOBDescr.Contains(q.Keyword!)));
@@ -51,7 +51,7 @@ public class AdoS0MfgLinePostsController : ControllerBase
         // 补展示字段:按 (Domain, ProdLine) 元组反查 LineMaster.Describe,避免跨域错配
         var domains = list.Select(x => x.Domain).Distinct().ToList();
         var lineCodes = list.Select(x => x.ProdLine).Distinct().ToList();
-        var lineMap = await _lineMasterRep.AsQueryable()
+        var lineMap = await _lineMasterRep.ScopedTo(tenantId)
             .Where(lm => domains.Contains(lm.Domain) && lineCodes.Contains(lm.Line))
             .Select(lm => new { lm.Domain, lm.Line, lm.Describe })
             .ToListAsync();
@@ -82,9 +82,10 @@ public class AdoS0MfgLinePostsController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetDetailAsync(long id)
     {
-        var header = await _postRep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var header = await _postRep.ByIdScopedAsync(id, tenantId);
         if (header == null) return NotFound();
-        var skills = await _skillRep.AsQueryable()
+        var skills = await _skillRep.ScopedTo(tenantId)
             .Where(x => x.LineSkillMasterId == id)
             .OrderBy(x => x.Id)
             .ToListAsync();
@@ -94,7 +95,8 @@ public class AdoS0MfgLinePostsController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0LineSkillMasterUpsertDto dto)
     {
-        var fkErr = await ValidatePersonSkillsAsync(dto);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var fkErr = await ValidatePersonSkillsAsync(dto, tenantId);
         if (fkErr != null) return BadRequest(new { message = fkErr });
 
         var db = _postRep.Context;
@@ -104,6 +106,7 @@ public class AdoS0MfgLinePostsController : ControllerBase
             var now = DateTime.Now;
             var post = new AdoS0LineSkillMaster
             {
+                TenantId = tenantId,
                 Domain = dto.Domain.Trim(),
                 ProdLine = dto.ProdLine.Trim(),
                 JOBNo = dto.JOBNo.Trim(),
@@ -121,7 +124,7 @@ public class AdoS0MfgLinePostsController : ControllerBase
 
             foreach (var s in dto.Skills)
             {
-                var row = MapSkill(post.Id, s);
+                var row = MapSkill(post.Id, s, tenantId);
                 await _skillRep.AsInsertable(row).ExecuteCommandAsync();
             }
 
@@ -138,10 +141,11 @@ public class AdoS0MfgLinePostsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0LineSkillMasterUpsertDto dto)
     {
-        var header = await _postRep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var header = await _postRep.ByIdScopedAsync(id, tenantId);
         if (header == null) return NotFound();
 
-        var fkErr = await ValidatePersonSkillsAsync(dto);
+        var fkErr = await ValidatePersonSkillsAsync(dto, tenantId);
         if (fkErr != null) return BadRequest(new { message = fkErr });
 
         var db = _postRep.Context;
@@ -163,7 +167,7 @@ public class AdoS0MfgLinePostsController : ControllerBase
             await _skillRep.AsDeleteable().Where(x => x.LineSkillMasterId == id).ExecuteCommandAsync();
             foreach (var s in dto.Skills)
             {
-                var row = MapSkill(id, s);
+                var row = MapSkill(id, s, tenantId);
                 await _skillRep.AsInsertable(row).ExecuteCommandAsync();
             }
 
@@ -180,7 +184,8 @@ public class AdoS0MfgLinePostsController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var header = await _postRep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var header = await _postRep.ByIdScopedAsync(id, tenantId);
         if (header == null) return NotFound();
 
         var db = _postRep.Context;
@@ -199,22 +204,23 @@ public class AdoS0MfgLinePostsController : ControllerBase
         }
     }
 
-    private async Task<string?> ValidatePersonSkillsAsync(AdoS0LineSkillMasterUpsertDto dto)
+    private async Task<string?> ValidatePersonSkillsAsync(AdoS0LineSkillMasterUpsertDto dto, long tenantId)
     {
         if (dto.Skills == null || dto.Skills.Count == 0)
             return null;
 
         var ids = dto.Skills.Select(s => s.PersonSkillId).Distinct().ToList();
-        var cnt = await _personSkillRep.AsQueryable()
+        var cnt = await _personSkillRep.ScopedTo(tenantId)
             .Where(p => ids.Contains(p.Id))
             .CountAsync();
         return cnt == ids.Count ? null : "存在无效的人员技能主数据引用";
     }
 
-    private static AdoS0LineSkillDetail MapSkill(long lineSkillMasterId, AdoS0LineSkillDetailUpsertDto s)
+    private static AdoS0LineSkillDetail MapSkill(long lineSkillMasterId, AdoS0LineSkillDetailUpsertDto s, long tenantId)
     {
         return new AdoS0LineSkillDetail
         {
+            TenantId = tenantId,
             LineSkillMasterId = lineSkillMasterId,
             PersonSkillId = s.PersonSkillId,
             RequiredLevel = s.RequiredLevel,

+ 10 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgPersonSkillAssignmentsController.cs

@@ -7,7 +7,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
 [ApiController]
 [Route("api/s0/manufacturing/person-skill-assignments")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgPersonSkillAssignmentsController : ControllerBase
 {
@@ -21,11 +20,12 @@ public class AdoS0MfgPersonSkillAssignmentsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0EmpSkillsQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.EffectivePage;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
-        var query = _rep.AsQueryable()
+        var query = _rep.ScopedTo(tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Domain), x => x.Domain == q.Domain)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Employee), x => x.Employee.Contains(q.Employee!))
             .WhereIF(!string.IsNullOrWhiteSpace(q.Site), x => x.Site != null && x.Site.Contains(q.Site!))
@@ -49,16 +49,19 @@ public class AdoS0MfgPersonSkillAssignmentsController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0EmpSkillsUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var now = DateTime.Now;
         var entity = new AdoS0EmpSkills
         {
+            TenantId = tenantId,
             Employee = dto.Employee.Trim(),
             EmployeeId = dto.EmployeeId,
             EmployeeName = dto.EmployeeName,
@@ -81,7 +84,8 @@ public class AdoS0MfgPersonSkillAssignmentsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0EmpSkillsUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
         entity.Employee = dto.Employee.Trim();
         entity.EmployeeId = dto.EmployeeId;
@@ -102,7 +106,8 @@ public class AdoS0MfgPersonSkillAssignmentsController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgPersonSkillsController.cs

@@ -142,7 +142,7 @@ public class AdoS0MfgPersonSkillsController : ControllerBase
         var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (item == null) return NotFound();
 
-        var refInfo = await _refChecker.PersonSkillReferencesAsync(item.Id, item.Code);
+        var refInfo = await _refChecker.PersonSkillReferencesAsync(tenantId, item.Id, item.Code);
         if (refInfo is { } r)
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
                 $"存在 {r.Count} 条 {r.Table} 引用该人员技能,无法删除");

+ 10 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgPreprocessElementParamsController.cs

@@ -9,7 +9,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 /// </summary>
 [ApiController]
 [Route("api/s0/manufacturing/preprocess-element-params")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgPreprocessElementParamsController : ControllerBase
 {
@@ -23,12 +22,13 @@ public class AdoS0MfgPreprocessElementParamsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0MfgPreprocessElementParamQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.Page;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
         // 去 domain 化(S0-DOMAIN-FILTER-REMOVAL-READONLY-1):不再按 Domain 过滤,租户隔离由 ITenantIdFilter 自动生效。
-        var query = _rep.AsQueryable()
+        var query = _rep.ScopedTo(tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
                 x => x.DeviceNumber.Contains(q.Keyword!)
                     || (x.DeviceDescr != null && x.DeviceDescr.Contains(q.Keyword!))
@@ -47,16 +47,19 @@ public class AdoS0MfgPreprocessElementParamsController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MfgPreprocessElementParamUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var now = DateTime.Now;
         var entity = new AdoS0MfgPreprocessElementParam
         {
+            TenantId = tenantId,
             Domain = dto.Domain.Trim(),
             DeviceNumber = dto.DeviceNumber.Trim(),
             DeviceDescr = dto.DeviceDescr,
@@ -80,7 +83,8 @@ public class AdoS0MfgPreprocessElementParamsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0MfgPreprocessElementParamUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         entity.Domain = dto.Domain.Trim();
@@ -104,7 +108,8 @@ public class AdoS0MfgPreprocessElementParamsController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });

+ 13 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgPreprocessElementsController.cs

@@ -8,7 +8,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
 [ApiController]
 [Route("api/s0/manufacturing/preprocess-elements")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgPreprocessElementsController : ControllerBase
 {
@@ -29,6 +28,7 @@ public class AdoS0MfgPreprocessElementsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0MfgPreprocessElementQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.EffectivePage;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
@@ -41,7 +41,7 @@ public class AdoS0MfgPreprocessElementsController : ControllerBase
         if (!string.IsNullOrWhiteSpace(qsDomain))
             domain = qsDomain.Trim();
 
-        var query = _rep.AsQueryable()
+        var query = _rep.ScopedTo(tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.MaterialCode), x => x.ItemNum.Contains(q.MaterialCode!))
             .WhereIF(!string.IsNullOrWhiteSpace(q.Op), x => x.Op != null && x.Op.Contains(q.Op!))
             .WhereIF(!string.IsNullOrWhiteSpace(q.ElementCode), x => x.ItemCode != null && x.ItemCode.Contains(q.ElementCode!))
@@ -60,7 +60,7 @@ public class AdoS0MfgPreprocessElementsController : ControllerBase
 
         // 去 domain 化:物料名补显不再按 Domain 等值;ItemMaster 带 ITenantIdFilter,租户内 ItemNum 唯一,以 IsActive/RecID 稳定排序兜底确定性。
         var distinctItemNums = list.Select(x => x.ItemNum).Distinct().ToList();
-        var items = await _itemRep.AsQueryable()
+        var items = await _itemRep.ScopedTo(tenantId)
             .Where(m => distinctItemNums.Contains(m.ItemNum))
             .OrderByDescending(m => m.IsActive)
             .OrderBy(m => m.Id)
@@ -71,6 +71,8 @@ public class AdoS0MfgPreprocessElementsController : ControllerBase
             .ToDictionary(g => g.Key, g => g.First().Descr);
 
         var opVals = list.Select(x => x.Op).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().Distinct().ToList();
+        // 通用码表 ado_s0_mfg_generalized_code_master 是全局只读展示码表(无 tenant_id 列、无 ITenantIdFilter),
+        // 仅用于把 Op 值翻成中文,不承载任何租户业务数据,故不施加租户条件。
         var opCodes = await _codeRep.AsQueryable()
             .Where(x => x.Domain == domain && x.FldName == "PrevProcessOp" && opVals.Contains(x.Val))
             .Select(x => new { x.Val, x.Comments })
@@ -100,16 +102,19 @@ public class AdoS0MfgPreprocessElementsController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MfgPreprocessElementUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var now = DateTime.Now;
         var entity = new AdoS0MfgPreprocessElement
         {
+            TenantId = tenantId,
             Domain = dto.Domain.Trim(),
             ItemNum = dto.ItemNum.Trim(),
             Op = dto.Op,
@@ -130,7 +135,8 @@ public class AdoS0MfgPreprocessElementsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0MfgPreprocessElementUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         entity.Domain = dto.Domain.Trim();
@@ -151,7 +157,8 @@ public class AdoS0MfgPreprocessElementsController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });

+ 17 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgProductionLinesController.cs

@@ -10,7 +10,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 /// </summary>
 [ApiController]
 [Route("api/s0/manufacturing/production-lines")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgProductionLinesController : ControllerBase
 {
@@ -27,7 +26,7 @@ public class AdoS0MfgProductionLinesController : ControllerBase
     /// B1-9:LineMaster 6 个 Location 变体的统一 existence check。
     /// 全部复用 AdoS0ReferenceChecker.LocationExistsAsync,空值放行。
     /// </summary>
-    private async Task<IActionResult?> ValidateLocationReferencesAsync(AdoS0LineMasterUpsertDto dto)
+    private async Task<IActionResult?> ValidateLocationReferencesAsync(AdoS0LineMasterUpsertDto dto, long tenantId)
     {
         var checks = new (string? Value, string Label)[]
         {
@@ -40,7 +39,7 @@ public class AdoS0MfgProductionLinesController : ControllerBase
         };
         foreach (var (value, label) in checks)
         {
-            if (!await _refChecker.LocationExistsAsync(value))
+            if (!await _refChecker.LocationExistsAsync(tenantId, value))
                 return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                     $"{label}编码 '{value}' 不存在于库位主数据");
         }
@@ -50,11 +49,12 @@ public class AdoS0MfgProductionLinesController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0LineMasterQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.EffectivePage;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
-        var query = _rep.AsQueryable()
+        var query = _rep.ScopedTo(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.Keyword),
@@ -104,19 +104,22 @@ public class AdoS0MfgProductionLinesController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0LineMasterUpsertDto dto)
     {
-        var locErr = await ValidateLocationReferencesAsync(dto);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var locErr = await ValidateLocationReferencesAsync(dto, tenantId);
         if (locErr != null) return locErr;
 
         var now = DateTime.Now;
         var entity = new AdoS0LineMaster
         {
+            TenantId = tenantId,
             CompanyRefId = dto.CompanyRefId,
             FactoryRefId = dto.FactoryRefId,
             Domain = dto.Domain.Trim(),
@@ -144,10 +147,11 @@ public class AdoS0MfgProductionLinesController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0LineMasterUpsertDto dto)
     {
-        var locErr = await ValidateLocationReferencesAsync(dto);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var locErr = await ValidateLocationReferencesAsync(dto, tenantId);
         if (locErr != null) return locErr;
 
-        var entity = await _rep.GetByIdAsync(id);
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         entity.CompanyRefId = dto.CompanyRefId;
@@ -175,7 +179,8 @@ public class AdoS0MfgProductionLinesController : ControllerBase
     [HttpPatch("{id:long}/toggle-enabled")]
     public async Task<IActionResult> ToggleEnabledAsync(long id, [FromBody] AdoS0ToggleEnabledDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         entity.IsActive = dto.IsEnabled;
@@ -187,10 +192,11 @@ public class AdoS0MfgProductionLinesController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
-        var refInfo = await _refChecker.ProductionLineReferencesAsync(item.Id, item.Line);
+        var refInfo = await _refChecker.ProductionLineReferencesAsync(tenantId, item.Id, item.Line);
         if (refInfo is { } r)
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
                 $"存在 {r.Count} 条 {r.Table} 引用该生产线,无法删除");

+ 17 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgRoutingOpDetailsController.cs

@@ -8,7 +8,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
 [ApiController]
 [Route("api/s0/manufacturing/routing-op-details")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgRoutingOpDetailsController : ControllerBase
 {
@@ -29,11 +28,12 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0MfgRoutingOpDetailQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.EffectivePage;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
-        var query = _rep.AsQueryable()
+        var query = _rep.ScopedTo(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.Keyword), x =>
@@ -54,18 +54,20 @@ public class AdoS0MfgRoutingOpDetailsController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MfgRoutingOpDetailUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var err = ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
 
         // B1 + B2:Create 一律严格校验,禁止降级
-        var matResult = await _refChecker.MaterialExistsInScopeAsync(dto.MaterialCode, dto.CompanyRefId, dto.FactoryRefId);
+        var matResult = await _refChecker.MaterialExistsInScopeAsync(tenantId, dto.MaterialCode, dto.CompanyRefId, dto.FactoryRefId);
         switch (matResult)
         {
             case MaterialScopeCheck.NotFound:
@@ -76,11 +78,13 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
                     $"物料编码 '{dto.MaterialCode}' 不属于当前公司/工厂 (CompanyRefId={dto.CompanyRefId}, FactoryRefId={dto.FactoryRefId})");
         }
 
-        if (!await _refChecker.WorkCenterExistsAsync(dto.WorkCenterCode))
+        if (!await _refChecker.WorkCenterExistsAsync(tenantId, dto.WorkCenterCode))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 $"工作中心编码 '{dto.WorkCenterCode}' 不存在于工作中心主数据");
 
         var entity = MapToEntity(dto);
+        // 租户只由后端可信上下文赋值,忽略 DTO 中任何 tenant 相关入参。
+        entity.TenantId = tenantId;
         entity.CreatedAt = DateTime.Now;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
         return Ok(entity);
@@ -89,14 +93,15 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0MfgRoutingOpDetailUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var err = ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
 
-        var entity = await _rep.GetByIdAsync(id);
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         // B1 + B2 + 历史兼容降级(D-03 严格条件)
-        var matResult = await _refChecker.MaterialExistsInScopeAsync(dto.MaterialCode, dto.CompanyRefId, dto.FactoryRefId);
+        var matResult = await _refChecker.MaterialExistsInScopeAsync(tenantId, dto.MaterialCode, dto.CompanyRefId, dto.FactoryRefId);
         if (matResult == MaterialScopeCheck.NotFound)
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 $"物料编码 '{dto.MaterialCode}' 不存在于物料主数据");
@@ -130,7 +135,7 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
             }
         }
 
-        if (!await _refChecker.WorkCenterExistsAsync(dto.WorkCenterCode))
+        if (!await _refChecker.WorkCenterExistsAsync(tenantId, dto.WorkCenterCode))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 $"工作中心编码 '{dto.WorkCenterCode}' 不存在于工作中心主数据");
 
@@ -143,7 +148,8 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
     [HttpPatch("{id:long}/toggle-enabled")]
     public async Task<IActionResult> ToggleEnabledAsync(long id, [FromBody] AdoS0ToggleEnabledDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
         entity.IsEnabled = dto.IsEnabled;
         entity.UpdatedAt = DateTime.Now;
@@ -154,7 +160,8 @@ public class AdoS0MfgRoutingOpDetailsController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });

+ 10 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgSopDocumentsController.cs

@@ -6,7 +6,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
 [ApiController]
 [Route("api/s0/manufacturing/sop-documents")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgSopDocumentsController : ControllerBase
 {
@@ -20,13 +19,14 @@ public class AdoS0MfgSopDocumentsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0MfgSopDocumentQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.Page;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
         var domain = q.Domain.Trim();
 
-        var baseQuery = _rep.AsQueryable()
+        var baseQuery = _rep.ScopedTo(tenantId)
             .Where(x => x.Domain == domain)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
                 x => (x.Line != null && x.Line.Contains(q.Keyword!))
@@ -70,15 +70,18 @@ public class AdoS0MfgSopDocumentsController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MfgSopDocumentUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var entity = new AdoS0QualitySegmentImage
         {
+            TenantId = tenantId,
             Domain = dto.Domain.Trim(),
             Line = dto.Line,
             ItemNum = dto.ItemNum,
@@ -96,7 +99,8 @@ public class AdoS0MfgSopDocumentsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0MfgSopDocumentUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
         entity.Domain = dto.Domain.Trim();
         entity.Line = dto.Line;
@@ -114,7 +118,8 @@ public class AdoS0MfgSopDocumentsController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });

+ 15 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgSopFileTypesController.cs

@@ -4,9 +4,12 @@ using Admin.NET.Plugin.AiDOP.Infrastructure;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
+/// <summary>
+/// S0 SOP 文件类型(ImageType 语义)。
+/// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP。
+/// </summary>
 [ApiController]
 [Route("api/s0/manufacturing/sop-file-types")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgSopFileTypesController : ControllerBase
 {
@@ -20,12 +23,13 @@ public class AdoS0MfgSopFileTypesController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0MfgSopFileTypeQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.Page;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
-        // 去 domain 化(S0-DOMAIN-FILTER-REMOVAL-READONLY-1):不再按 Domain 过滤,租户隔离由 ITenantIdFilter 自动生效
-        var query = _rep.AsQueryable()
+        // 去 domain 化(S0-DOMAIN-FILTER-REMOVAL-READONLY-1):不按 Domain 过滤;租户边界由显式 tenant 条件保证
+        var query = _rep.ScopedTo(tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
                 x => x.ImageTypeID.Contains(q.Keyword!) || (x.Descr != null && x.Descr.Contains(q.Keyword!)));
 
@@ -42,15 +46,18 @@ public class AdoS0MfgSopFileTypesController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MfgSopFileTypeUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var entity = new AdoS0ImageType
         {
+            TenantId = tenantId,
             Domain = dto.Domain.Trim(),
             ImageTypeID = dto.ImageTypeID.Trim(),
             Descr = dto.Descr
@@ -62,7 +69,8 @@ public class AdoS0MfgSopFileTypesController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0MfgSopFileTypeUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         entity.Domain = dto.Domain.Trim();
@@ -76,7 +84,8 @@ public class AdoS0MfgSopFileTypesController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
         await _rep.DeleteAsync(item);

+ 12 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgStandardOperationsController.cs

@@ -9,7 +9,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 /// </summary>
 [ApiController]
 [Route("api/s0/manufacturing/standard-operations")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgStandardOperationsController : ControllerBase
 {
@@ -23,11 +22,12 @@ public class AdoS0MfgStandardOperationsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0StdOpMasterQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.EffectivePage;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
-        var query = _rep.AsQueryable()
+        var query = _rep.ScopedTo(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.Keyword), x => x.StdOp.Contains(q.Keyword!) || (x.StdOpCode != null && x.StdOpCode.Contains(q.Keyword!)))
@@ -48,20 +48,23 @@ public class AdoS0MfgStandardOperationsController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0StdOpMasterUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var stdOpCode = dto.StdOpCode.Trim();
-        if (await _rep.IsAnyAsync(x => x.CompanyRefId == dto.CompanyRefId && x.FactoryRefId == dto.FactoryRefId && x.StdOpCode == stdOpCode))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.CompanyRefId == dto.CompanyRefId && x.FactoryRefId == dto.FactoryRefId && x.StdOpCode == stdOpCode))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "标准工序编码已存在");
 
         var now = DateTime.Now;
         var entity = new AdoS0StdOpMaster
         {
+            TenantId = tenantId,
             CompanyRefId = dto.CompanyRefId,
             FactoryRefId = dto.FactoryRefId,
             Domain = dto.Domain.Trim(),
@@ -80,11 +83,12 @@ public class AdoS0MfgStandardOperationsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0StdOpMasterUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         var stdOpCode = dto.StdOpCode.Trim();
-        if (await _rep.IsAnyAsync(x => x.Id != id && x.CompanyRefId == dto.CompanyRefId && x.FactoryRefId == dto.FactoryRefId && x.StdOpCode == stdOpCode))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.CompanyRefId == dto.CompanyRefId && x.FactoryRefId == dto.FactoryRefId && x.StdOpCode == stdOpCode))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "标准工序编码已存在");
 
         entity.CompanyRefId = dto.CompanyRefId;
@@ -103,7 +107,8 @@ public class AdoS0MfgStandardOperationsController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
         await _rep.DeleteAsync(item);

+ 15 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgWorkCentersController.cs

@@ -7,7 +7,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
 [ApiController]
 [Route("api/s0/manufacturing/work-centers")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgWorkCentersController : ControllerBase
 {
@@ -23,12 +22,13 @@ public class AdoS0MfgWorkCentersController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0WorkCtrMasterQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.Page;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
         var domain = q.Domain.Trim();
-        var query = _rep.AsQueryable()
+        var query = _rep.ScopedTo(tenantId)
             .Where(x => x.Domain == domain)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
                 x => x.WorkCtr.Contains(q.Keyword!)
@@ -64,25 +64,28 @@ public class AdoS0MfgWorkCentersController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0WorkCtrMasterUpsertDto dto)
     {
-        if (!await _refChecker.DepartmentExistsAsync(dto.Department))
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        if (!await _refChecker.DepartmentExistsAsync(tenantId, dto.Department))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 $"部门编码 '{dto.Department}' 不存在于部门主数据");
 
         var workCtr = dto.WorkCtr.Trim();
         var domain = dto.Domain.Trim();
-        if (await _rep.IsAnyAsync(x => x.Domain == domain && x.WorkCtr == workCtr))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Domain == domain && x.WorkCtr == workCtr))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "工作中心编码已存在");
 
         var now = DateTime.Now;
         var entity = new AdoS0WorkCtrMaster
         {
+            TenantId = tenantId,
             WorkCtr = dto.WorkCtr.Trim(),
             Descr = dto.Descr,
             Department = dto.Department,
@@ -100,16 +103,17 @@ public class AdoS0MfgWorkCentersController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0WorkCtrMasterUpsertDto dto)
     {
-        if (!await _refChecker.DepartmentExistsAsync(dto.Department))
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        if (!await _refChecker.DepartmentExistsAsync(tenantId, dto.Department))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 $"部门编码 '{dto.Department}' 不存在于部门主数据");
 
-        var entity = await _rep.GetByIdAsync(id);
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         var workCtr = dto.WorkCtr.Trim();
         var domain = dto.Domain.Trim();
-        if (await _rep.IsAnyAsync(x => x.Id != id && x.Domain == domain && x.WorkCtr == workCtr))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.Domain == domain && x.WorkCtr == workCtr))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "工作中心编码已存在");
 
         entity.WorkCtr = dto.WorkCtr.Trim();
@@ -126,10 +130,11 @@ public class AdoS0MfgWorkCentersController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
-        var refInfo = await _refChecker.WorkCenterReferencesAsync(item.WorkCtr);
+        var refInfo = await _refChecker.WorkCenterReferencesAsync(tenantId, item.WorkCtr);
         if (refInfo is { } r)
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
                 $"存在 {r.Count} 条 {r.Table} 引用该工作中心,无法删除");

+ 29 - 21
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0ProductStructuresController.cs

@@ -11,7 +11,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 /// </summary>
 [ApiController]
 [Route("api/s0/manufacturing/standard-bom-lines")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0ProductStructuresController : ControllerBase
 {
@@ -32,11 +31,12 @@ public class AdoS0ProductStructuresController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0ProductStructureQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.EffectivePage;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
-        var query = _masterRep.AsQueryable()
+        var query = _masterRep.ScopedTo(tenantId)
             .WhereIF(q.CompanyRefId.HasValue, x => x.CompanyRefId == q.CompanyRefId!.Value)
             .WhereIF(q.FactoryRefId.HasValue, x => x.FactoryRefId == q.FactoryRefId!.Value)
             .WhereIF(q.IsEnabled.HasValue, x => x.IsEnabled == q.IsEnabled!.Value);
@@ -60,7 +60,7 @@ public class AdoS0ProductStructuresController : ControllerBase
         var factoryId = q.FactoryRefId ?? list[0].FactoryRefId;
         var companyId = q.CompanyRefId ?? list[0].CompanyRefId;
 
-        var phantomComponents = await _masterRep.AsQueryable()
+        var phantomComponents = await _masterRep.ScopedTo(tenantId)
             .Where(x => x.FactoryRefId == factoryId && x.CompanyRefId == companyId
                 && x.StructureType != null && x.StructureType.ToUpper() == "X")
             .Select(x => x.ComponentMaterialId)
@@ -69,14 +69,14 @@ public class AdoS0ProductStructuresController : ControllerBase
         var phantomSet = phantomComponents.ToHashSet();
 
         var materialIds = list.SelectMany(m => new[] { m.ParentMaterialId, m.ComponentMaterialId }).Distinct().ToList();
-        var materials = await _materialRep.AsQueryable()
+        var materials = await _materialRep.ScopedTo(tenantId)
             .Where(i => materialIds.Contains(i.Id))
             .ToListAsync();
         var matMap = materials.ToDictionary(i => i.Id, i => i);
 
         var pids = list.Select(x => x.ParentMaterialId).Distinct().ToList();
         var cids = list.Select(x => x.ComponentMaterialId).Distinct().ToList();
-        var allOps = await _opRep.AsQueryable()
+        var allOps = await _opRep.ScopedTo(tenantId)
             .Where(o => o.FactoryRefId == factoryId && pids.Contains(o.ParentMaterialId) && cids.Contains(o.ComponentMaterialId))
             .OrderBy(o => o.Op)
             .ToListAsync();
@@ -135,9 +135,10 @@ public class AdoS0ProductStructuresController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetDetailAsync(long id)
     {
-        var m = await _masterRep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var m = await _masterRep.ByIdScopedAsync(id, tenantId);
         if (m == null) return NotFound();
-        var opNos = await _opRep.AsQueryable()
+        var opNos = await _opRep.ScopedTo(tenantId)
             .Where(o => o.ProductStructureMasterId == id)
             .OrderBy(o => o.SortNo)
             .Select(o => o.Op)
@@ -148,13 +149,14 @@ public class AdoS0ProductStructuresController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ProductStructureUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var err = AdoS0ProductStructureRules.ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
 
-        var fkErr = await ValidateMaterialRefsAsync(dto.ParentMaterialId, dto.ComponentMaterialId);
+        var fkErr = await ValidateMaterialRefsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         if (fkErr != null) return BadRequest(new { message = fkErr });
 
-        var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId);
+        var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         if (itemErr != null) return BadRequest(new { message = itemErr });
 
         var db = _masterRep.Context;
@@ -162,11 +164,13 @@ public class AdoS0ProductStructuresController : ControllerBase
         try
         {
             var entity = MapMaster(dto);
+            // 租户只由后端可信上下文赋值,忽略 DTO 中任何 tenant 相关入参。
+            entity.TenantId = tenantId;
             entity.ParentItem = parentItem!;
             entity.ComponentItem = componentItem!;
             entity.CreatedAt = DateTime.Now;
             entity = await _masterRep.AsInsertable(entity).ExecuteReturnEntityAsync();
-            await SyncOpsAsync(entity.Id, dto, entity.ParentMaterialId, entity.ComponentMaterialId);
+            await SyncOpsAsync(entity.Id, dto, entity.ParentMaterialId, entity.ComponentMaterialId, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(entity.Id);
         }
@@ -180,16 +184,17 @@ public class AdoS0ProductStructuresController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ProductStructureUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var err = AdoS0ProductStructureRules.ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
 
-        var existing = await _masterRep.GetByIdAsync(id);
+        var existing = await _masterRep.ByIdScopedAsync(id, tenantId);
         if (existing == null) return NotFound();
 
-        var fkErr = await ValidateMaterialRefsAsync(dto.ParentMaterialId, dto.ComponentMaterialId);
+        var fkErr = await ValidateMaterialRefsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         if (fkErr != null) return BadRequest(new { message = fkErr });
 
-        var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId);
+        var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         if (itemErr != null) return BadRequest(new { message = itemErr });
 
         var db = _masterRep.Context;
@@ -203,7 +208,7 @@ public class AdoS0ProductStructuresController : ControllerBase
             await _masterRep.AsUpdateable(existing).ExecuteCommandAsync();
 
             await _opRep.AsDeleteable().Where(o => o.ProductStructureMasterId == id).ExecuteCommandAsync();
-            await SyncOpsAsync(id, dto, existing.ParentMaterialId, existing.ComponentMaterialId);
+            await SyncOpsAsync(id, dto, existing.ParentMaterialId, existing.ComponentMaterialId, tenantId);
 
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
@@ -218,7 +223,8 @@ public class AdoS0ProductStructuresController : ControllerBase
     [HttpPatch("{id:long}/toggle-enabled")]
     public async Task<IActionResult> ToggleEnabledAsync(long id, [FromBody] AdoS0ToggleEnabledDto dto)
     {
-        var entity = await _masterRep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _masterRep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
         entity.IsEnabled = dto.IsEnabled;
         entity.UpdatedAt = DateTime.Now;
@@ -229,7 +235,8 @@ public class AdoS0ProductStructuresController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var entity = await _masterRep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _masterRep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         var db = _masterRep.Context;
@@ -274,7 +281,7 @@ public class AdoS0ProductStructuresController : ControllerBase
         m.IsEnabled = dto.IsEnabled;
     }
 
-    private async Task SyncOpsAsync(long masterId, AdoS0ProductStructureUpsertDto dto, long parentMaterialId, long componentMaterialId)
+    private async Task SyncOpsAsync(long masterId, AdoS0ProductStructureUpsertDto dto, long parentMaterialId, long componentMaterialId, long tenantId)
     {
         var distinct = dto.OpNos?.Where(n => n > 0).Distinct().OrderBy(n => n).ToList() ?? new List<int>();
         var sort = 0;
@@ -282,6 +289,7 @@ public class AdoS0ProductStructuresController : ControllerBase
         {
             var row = new AdoS0ProductStructureOp
             {
+                TenantId = tenantId,
                 CompanyRefId = dto.CompanyRefId,
                 FactoryRefId = dto.FactoryRefId,
                 ProductStructureMasterId = masterId,
@@ -299,9 +307,9 @@ public class AdoS0ProductStructuresController : ControllerBase
     /// 解析父/子物料的真实 ItemNum 文本,落 ProductStructureMaster.ParentItem/ComponentItem(NOT NULL)。
     /// 物料不存在或编码为空时返回业务错误(400),避免插 NULL 导致裸 500。
     /// </summary>
-    private async Task<(string? ParentItem, string? ComponentItem, string? Error)> ResolveItemNumsAsync(long parentId, long componentId)
+    private async Task<(string? ParentItem, string? ComponentItem, string? Error)> ResolveItemNumsAsync(long parentId, long componentId, long tenantId)
     {
-        var mats = await _materialRep.AsQueryable()
+        var mats = await _materialRep.ScopedTo(tenantId)
             .Where(m => m.Id == parentId || m.Id == componentId)
             .Select(m => new { m.Id, m.ItemNum })
             .ToListAsync();
@@ -320,10 +328,10 @@ public class AdoS0ProductStructuresController : ControllerBase
     // 物料引用只校验 Id 真实存在:物料主数据用 legacy 数值 org 域(如 1000/8010),而标准 BOM 表单
     // 公司/工厂来自 SysOrg 雪花 id(如 1329900200001),两域直接强比较恒不成立、会误拦截真实物料引用。
     // 对齐货源清单 srm 的"引用真实存在即可保存"口径,不再比较物料 CompanyRefId/FactoryRefId。
-    private async Task<string?> ValidateMaterialRefsAsync(long parentId, long componentId)
+    private async Task<string?> ValidateMaterialRefsAsync(long parentId, long componentId, long tenantId)
     {
         var ids = parentId == componentId ? new[] { parentId } : new[] { parentId, componentId };
-        var existingCount = await _materialRep.AsQueryable()
+        var existingCount = await _materialRep.ScopedTo(tenantId)
             .Where(m => ids.Contains(m.Id))
             .CountAsync();
         return existingCount == ids.Length ? null : "存在无效的物料主数据引用(公司/工厂或 Id 不匹配)";

+ 5 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0WorkOrdControlController.cs

@@ -8,7 +8,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 /// </summary>
 [ApiController]
 [Route("api/s0/manufacturing/work-ord-control")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0WorkOrdControlController : ControllerBase
 {
@@ -22,8 +21,9 @@ public class AdoS0WorkOrdControlController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetOneAsync([FromQuery] AdoS0WorkOrdControlGetDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var domain = q.Domain.Trim();
-        var item = await _rep.AsQueryable()
+        var item = await _rep.ScopedTo(tenantId)
             .Where(x => x.Domain == domain)
             .OrderBy(x => x.Id)
             .FirstAsync();
@@ -34,6 +34,7 @@ public class AdoS0WorkOrdControlController : ControllerBase
     [HttpPost("upsert")]
     public async Task<IActionResult> UpsertAsync([FromBody] AdoS0WorkOrdControlUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var now = DateTime.Now;
         var domain = dto.Domain.Trim();
 
@@ -43,7 +44,7 @@ public class AdoS0WorkOrdControlController : ControllerBase
 
         if (entity == null)
         {
-            entity = await _rep.AsQueryable()
+            entity = await _rep.ScopedTo(tenantId)
                 .Where(x => x.Domain == domain)
                 .OrderBy(x => x.Id)
                 .FirstAsync();
@@ -53,6 +54,7 @@ public class AdoS0WorkOrdControlController : ControllerBase
         {
             entity = new AdoS0WorkOrdControl
             {
+                TenantId = tenantId,
                 Domain = domain,
                 EnteringPer = dto.EnteringPer,
                 PaintingPer = dto.PaintingPer,

+ 15 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QmsSpecImportController.cs

@@ -15,7 +15,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
 /// </summary>
 [ApiController]
 [Route("api/s0/quality")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsSpecImportController : ControllerBase
 {
@@ -71,6 +70,7 @@ public class AdoS0QmsSpecImportController : ControllerBase
     [HttpPost("raw-inspection-specs/import")]
     public async Task<IActionResult> RawImport(IFormFile file)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (file == null || file.Length == 0) return BadRequest(new { message = "请上传 Excel 文件" });
         var result = await RunImportAsync<AdoS0QmsRawInspectionSpecImportRow, AdoS0QmsRawInspectionSpec, AdoS0QmsRawInspectionSpecEntry>(
             file, r => r.FileNumber, RawDetailHasValue, _rawRep, _rawEntryRep,
@@ -100,13 +100,15 @@ public class AdoS0QmsSpecImportController : ControllerBase
                 SamplingScheme = NullIfWhiteSpace(r.SamplingScheme),
                 UpperLimit = NullIfWhiteSpace(r.UpperLimit),
                 LowerLimit = NullIfWhiteSpace(r.LowerLimit),
-            });
+            },
+            tenantId);
         return Ok(result);
     }
 
     [HttpPost("process-inspection-specs/import")]
     public async Task<IActionResult> ProcessImport(IFormFile file)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (file == null || file.Length == 0) return BadRequest(new { message = "请上传 Excel 文件" });
         var result = await RunImportAsync<AdoS0QmsProcessLikeInspectionSpecImportRow, AdoS0QmsProcessInspectionSpec, AdoS0QmsProcessInspectionSpecEntry>(
             file, r => r.FileNumber, ProcessLikeDetailHasValue, _processRep, _processEntryRep,
@@ -122,13 +124,15 @@ public class AdoS0QmsSpecImportController : ControllerBase
                 Version = r.Version,
             },
             m => m.Id,
-            (r, mid) => BuildProcessLikeEntry<AdoS0QmsProcessInspectionSpecEntry>(r, mid));
+            (r, mid) => BuildProcessLikeEntry<AdoS0QmsProcessInspectionSpecEntry>(r, mid),
+            tenantId);
         return Ok(result);
     }
 
     [HttpPost("fqc-inspection-specs/import")]
     public async Task<IActionResult> FqcImport(IFormFile file)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (file == null || file.Length == 0) return BadRequest(new { message = "请上传 Excel 文件" });
         var result = await RunImportAsync<AdoS0QmsProcessLikeInspectionSpecImportRow, AdoS0QmsFqcInspectionSpec, AdoS0QmsFqcInspectionSpecEntry>(
             file, r => r.FileNumber, ProcessLikeDetailHasValue, _fqcRep, _fqcEntryRep,
@@ -144,13 +148,15 @@ public class AdoS0QmsSpecImportController : ControllerBase
                 Version = r.Version,
             },
             m => m.Id,
-            (r, mid) => BuildProcessLikeEntry<AdoS0QmsFqcInspectionSpecEntry>(r, mid));
+            (r, mid) => BuildProcessLikeEntry<AdoS0QmsFqcInspectionSpecEntry>(r, mid),
+            tenantId);
         return Ok(result);
     }
 
     [HttpPost("oqc-inspection-specs/import")]
     public async Task<IActionResult> OqcImport(IFormFile file)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (file == null || file.Length == 0) return BadRequest(new { message = "请上传 Excel 文件" });
         var result = await RunImportAsync<AdoS0QmsProcessLikeInspectionSpecImportRow, AdoS0QmsOqcInspectionSpec, AdoS0QmsOqcInspectionSpecEntry>(
             file, r => r.FileNumber, ProcessLikeDetailHasValue, _oqcRep, _oqcEntryRep,
@@ -166,7 +172,8 @@ public class AdoS0QmsSpecImportController : ControllerBase
                 Version = r.Version,
             },
             m => m.Id,
-            (r, mid) => BuildProcessLikeEntry<AdoS0QmsOqcInspectionSpecEntry>(r, mid));
+            (r, mid) => BuildProcessLikeEntry<AdoS0QmsOqcInspectionSpecEntry>(r, mid),
+            tenantId);
         return Ok(result);
     }
 
@@ -224,7 +231,8 @@ public class AdoS0QmsSpecImportController : ControllerBase
         SqlSugarRepository<TEntry> entryRep,
         Func<TRow, string, TMaster> masterFactory,
         Func<TMaster, long> idOf,
-        Func<TRow, long, TEntry> entryFactory)
+        Func<TRow, long, TEntry> entryFactory,
+        long tenantId)
         where TRow : class, new()
         where TMaster : class, new()
         where TEntry : class, new()
@@ -233,7 +241,7 @@ public class AdoS0QmsSpecImportController : ControllerBase
         var result = new AdoS0QmsSpecImportResultDto();
         var groups = BuildGroups(rows, keySelector, result);
 
-        var tid = QmsTenantScope.Current();
+        var tid = tenantId;
         var db = masterRep.Context;
         await db.Ado.BeginTranAsync();
         try

+ 81 - 58
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityAggregateControllers.cs

@@ -7,7 +7,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
 
 [ApiController]
 [Route("api/s0/quality/inspection-bases")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsInspectionBasesController : ControllerBase
 {
@@ -25,8 +24,9 @@ public class AdoS0QmsInspectionBasesController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.Number != null && x.Number.Contains(q.Keyword!)) ||
                 (x.Name != null && x.Name.Contains(q.Keyword!)));
@@ -38,30 +38,36 @@ public class AdoS0QmsInspectionBasesController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetDetailAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var items = await _entryRep.AsQueryable().Where(x => x.MasterId == id).OrderBy(x => x.Seq).OrderBy(x => x.Id).ToListAsync();
         return Ok(new { master, items });
     }
 
     [HttpGet("options")]
-    public async Task<IActionResult> GetOptionsAsync() => Ok(await _rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current())
-        .OrderBy(x => x.Number)
-        .Select(x => new AdoS0QualitySimpleOptionDto { Value = x.Id, Label = (x.Number ?? "") + " / " + (x.Name ?? "") })
-        .ToListAsync());
+    public async Task<IActionResult> GetOptionsAsync()
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return Ok(await _rep.AsQueryable().Where(x => x.TenantId == tenantId)
+            .OrderBy(x => x.Number)
+            .Select(x => new AdoS0QualitySimpleOptionDto { Value = x.Id, Label = (x.Number ?? "") + " / " + (x.Name ?? "") })
+            .ToListAsync());
+    }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsInspectionBasisUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
         {
             var master = new AdoS0QmsInspectionBasis();
             ApplyInspectionBasis(master, dto, true);
-            master.TenantId = QmsTenantScope.Current();
+            master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncInspectionBasisEntriesAsync(master.Id, dto.Items);
+            await SyncInspectionBasisEntriesAsync(master.Id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -75,7 +81,8 @@ public class AdoS0QmsInspectionBasesController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsInspectionBasisUpsertDto dto)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -84,7 +91,7 @@ public class AdoS0QmsInspectionBasesController : ControllerBase
             ApplyInspectionBasis(master, dto, false);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncInspectionBasisEntriesAsync(id, dto.Items);
+            await SyncInspectionBasisEntriesAsync(id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -98,7 +105,8 @@ public class AdoS0QmsInspectionBasesController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -130,9 +138,8 @@ public class AdoS0QmsInspectionBasesController : ControllerBase
         entity.ModifyTime = DateTime.Now;
     }
 
-    private async Task SyncInspectionBasisEntriesAsync(long masterId, IEnumerable<AdoS0QmsInspectionBasisEntryDto> items)
+    private async Task SyncInspectionBasisEntriesAsync(long masterId, IEnumerable<AdoS0QmsInspectionBasisEntryDto> items, long tenantId)
     {
-        var tenantId = QmsTenantScope.Current();
         var seq = 1L;
         foreach (var item in items)
         {
@@ -152,7 +159,6 @@ public class AdoS0QmsInspectionBasesController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/inspection-standards")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsInspectionStandardsController : ControllerBase
 {
@@ -170,8 +176,9 @@ public class AdoS0QmsInspectionStandardsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.Number != null && x.Number.Contains(q.Keyword!)) ||
                 (x.Name != null && x.Name.Contains(q.Keyword!)));
@@ -183,30 +190,36 @@ public class AdoS0QmsInspectionStandardsController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetDetailAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var items = await _entryRep.AsQueryable().Where(x => x.MasterId == id).OrderBy(x => x.Seq).OrderBy(x => x.Id).ToListAsync();
         return Ok(new { master, items });
     }
 
     [HttpGet("options")]
-    public async Task<IActionResult> GetOptionsAsync() => Ok(await _rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current())
-        .OrderBy(x => x.Number)
-        .Select(x => new AdoS0QualitySimpleOptionDto { Value = x.Id, Label = (x.Number ?? "") + " / " + (x.Name ?? "") })
-        .ToListAsync());
+    public async Task<IActionResult> GetOptionsAsync()
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return Ok(await _rep.AsQueryable().Where(x => x.TenantId == tenantId)
+            .OrderBy(x => x.Number)
+            .Select(x => new AdoS0QualitySimpleOptionDto { Value = x.Id, Label = (x.Number ?? "") + " / " + (x.Name ?? "") })
+            .ToListAsync());
+    }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsInspectionStandardUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
         {
             var master = new AdoS0QmsInspectionStandard();
             ApplyInspectionStandard(master, dto);
-            master.TenantId = QmsTenantScope.Current();
+            master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncInspectionStandardEntriesAsync(master.Id, dto.Items);
+            await SyncInspectionStandardEntriesAsync(master.Id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -220,7 +233,8 @@ public class AdoS0QmsInspectionStandardsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsInspectionStandardUpsertDto dto)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -229,7 +243,7 @@ public class AdoS0QmsInspectionStandardsController : ControllerBase
             ApplyInspectionStandard(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncInspectionStandardEntriesAsync(id, dto.Items);
+            await SyncInspectionStandardEntriesAsync(id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -243,7 +257,8 @@ public class AdoS0QmsInspectionStandardsController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -273,9 +288,8 @@ public class AdoS0QmsInspectionStandardsController : ControllerBase
         entity.EnableStatus = NullIfWhiteSpace(dto.EnableStatus);
     }
 
-    private async Task SyncInspectionStandardEntriesAsync(long masterId, IEnumerable<AdoS0QmsInspectionStandardEntryDto> items)
+    private async Task SyncInspectionStandardEntriesAsync(long masterId, IEnumerable<AdoS0QmsInspectionStandardEntryDto> items, long tenantId)
     {
-        var tenantId = QmsTenantScope.Current();
         var seq = 1L;
         foreach (var item in items)
         {
@@ -304,7 +318,6 @@ public class AdoS0QmsInspectionStandardsController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/inspection-plans")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsInspectionPlansController : ControllerBase
 {
@@ -322,8 +335,9 @@ public class AdoS0QmsInspectionPlansController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.Number != null && x.Number.Contains(q.Keyword!)) ||
                 (x.Name != null && x.Name.Contains(q.Keyword!)));
@@ -335,7 +349,8 @@ public class AdoS0QmsInspectionPlansController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetDetailAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var items = await _entryRep.AsQueryable().Where(x => x.MasterId == id).OrderBy(x => x.Seq).OrderBy(x => x.Id).ToListAsync();
         return Ok(new { master, items });
@@ -344,15 +359,16 @@ public class AdoS0QmsInspectionPlansController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsInspectionPlanUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
         {
             var master = new AdoS0QmsInspectionPlan();
             ApplyInspectionPlan(master, dto, true);
-            master.TenantId = QmsTenantScope.Current();
+            master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncInspectionPlanEntriesAsync(master.Id, dto.Items);
+            await SyncInspectionPlanEntriesAsync(master.Id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -366,7 +382,8 @@ public class AdoS0QmsInspectionPlansController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsInspectionPlanUpsertDto dto)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -375,7 +392,7 @@ public class AdoS0QmsInspectionPlansController : ControllerBase
             ApplyInspectionPlan(master, dto, false);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncInspectionPlanEntriesAsync(id, dto.Items);
+            await SyncInspectionPlanEntriesAsync(id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -389,7 +406,8 @@ public class AdoS0QmsInspectionPlansController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -422,9 +440,8 @@ public class AdoS0QmsInspectionPlansController : ControllerBase
         entity.ModifyTime = DateTime.Now;
     }
 
-    private async Task SyncInspectionPlanEntriesAsync(long masterId, IEnumerable<AdoS0QmsInspectionPlanEntryDto> items)
+    private async Task SyncInspectionPlanEntriesAsync(long masterId, IEnumerable<AdoS0QmsInspectionPlanEntryDto> items, long tenantId)
     {
-        var tenantId = QmsTenantScope.Current();
         var seq = 1;
         foreach (var item in items)
         {
@@ -456,7 +473,6 @@ public class AdoS0QmsInspectionPlansController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/raw-inspection-specs")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsRawInspectionSpecsController : ControllerBase
 {
@@ -474,8 +490,9 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.FileNumber != null && x.FileNumber.Contains(q.Keyword!)) ||
                 (x.RawMaterialName != null && x.RawMaterialName.Contains(q.Keyword!)) ||
@@ -488,7 +505,8 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetDetailAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var items = await _entryRep.AsQueryable().Where(x => x.MasterId == id).OrderBy(x => x.Seq).OrderBy(x => x.Id).ToListAsync();
         return Ok(new { master, items });
@@ -497,15 +515,16 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsRawInspectionSpecUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
         {
             var master = new AdoS0QmsRawInspectionSpec();
             ApplyRawInspectionSpec(master, dto);
-            master.TenantId = QmsTenantScope.Current();
+            master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncRawInspectionSpecEntriesAsync(master.Id, dto.Items);
+            await SyncRawInspectionSpecEntriesAsync(master.Id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -519,7 +538,8 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsRawInspectionSpecUpsertDto dto)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -528,7 +548,7 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
             ApplyRawInspectionSpec(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncRawInspectionSpecEntriesAsync(id, dto.Items);
+            await SyncRawInspectionSpecEntriesAsync(id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -542,7 +562,8 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -576,9 +597,8 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
         entity.Title = NullIfWhiteSpace(dto.Title);
     }
 
-    private async Task SyncRawInspectionSpecEntriesAsync(long masterId, IEnumerable<AdoS0QmsRawInspectionSpecEntryDto> items)
+    private async Task SyncRawInspectionSpecEntriesAsync(long masterId, IEnumerable<AdoS0QmsRawInspectionSpecEntryDto> items, long tenantId)
     {
-        var tenantId = QmsTenantScope.Current();
         var seq = 1;
         foreach (var item in items)
         {
@@ -604,7 +624,6 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/process-inspection-specs")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
 {
@@ -622,8 +641,9 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.FileNumber != null && x.FileNumber.Contains(q.Keyword!)) ||
                 (x.ApplicableModel != null && x.ApplicableModel.Contains(q.Keyword!)) ||
@@ -636,7 +656,8 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetDetailAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var items = await _entryRep.AsQueryable().Where(x => x.MasterId == id).OrderBy(x => x.Id).ToListAsync();
         return Ok(new { master, items });
@@ -645,15 +666,16 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsProcessInspectionSpecUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
         {
             var master = new AdoS0QmsProcessInspectionSpec();
             ApplyProcessInspectionSpec(master, dto);
-            master.TenantId = QmsTenantScope.Current();
+            master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncProcessInspectionSpecEntriesAsync(master.Id, dto.Items);
+            await SyncProcessInspectionSpecEntriesAsync(master.Id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -667,7 +689,8 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsProcessInspectionSpecUpsertDto dto)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -676,7 +699,7 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
             ApplyProcessInspectionSpec(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncProcessInspectionSpecEntriesAsync(id, dto.Items);
+            await SyncProcessInspectionSpecEntriesAsync(id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -690,7 +713,8 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -720,9 +744,8 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
         entity.Version = dto.Version;
     }
 
-    private async Task SyncProcessInspectionSpecEntriesAsync(long masterId, IEnumerable<AdoS0QmsProcessInspectionSpecEntryDto> items)
+    private async Task SyncProcessInspectionSpecEntriesAsync(long masterId, IEnumerable<AdoS0QmsProcessInspectionSpecEntryDto> items, long tenantId)
     {
-        var tenantId = QmsTenantScope.Current();
         foreach (var item in items)
         {
             var entity = new AdoS0QmsProcessInspectionSpecEntry

+ 144 - 52
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityBaseDataControllers.cs

@@ -8,7 +8,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
 
 [ApiController]
 [Route("api/s0/quality/base-types")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsQualityBaseTypesController : ControllerBase
 {
@@ -22,8 +21,9 @@ public class AdoS0QmsQualityBaseTypesController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 x.TypeCategory.Contains(q.Keyword!)
                 || x.TypeCode.Contains(q.Keyword!)
@@ -36,15 +36,20 @@ public class AdoS0QmsQualityBaseTypesController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    public async Task<IActionResult> GetAsync(long id) => OkOrNotFound(await _rep.GetByIdAsync(id));
+    public async Task<IActionResult> GetAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return OkOrNotFound(await _rep.ByIdScopedByColumnAsync(id, tenantId));
+    }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsQualityBaseTypeUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         // C4 唯一性 pre-check:(TypeCategory, TypeCode) 全局唯一
         var category = dto.TypeCategory.Trim();
         var code = dto.TypeCode.Trim();
-        if (await _rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current()).AnyAsync(x => x.TypeCategory == category && x.TypeCode == code))
+        if (await _rep.AsQueryable().Where(x => x.TenantId == tenantId).AnyAsync(x => x.TypeCategory == category && x.TypeCode == code))
         {
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.QualityBaseTypeDuplicate,
                 $"质量基础类型 '{category} / {code}' 已存在");
@@ -52,7 +57,7 @@ public class AdoS0QmsQualityBaseTypesController : ControllerBase
 
         var entity = new AdoS0QmsQualityBaseType();
         ApplyQualityBaseType(entity, dto, true);
-        entity.TenantId = QmsTenantScope.Current();
+        entity.TenantId = tenantId;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
         return Ok(entity);
     }
@@ -60,13 +65,14 @@ public class AdoS0QmsQualityBaseTypesController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsQualityBaseTypeUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         // C4 唯一性 pre-check:(TypeCategory, TypeCode) 全局唯一,排除自身
         var category = dto.TypeCategory.Trim();
         var code = dto.TypeCode.Trim();
-        if (await _rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current()).AnyAsync(x => x.TypeCategory == category && x.TypeCode == code && x.Id != id))
+        if (await _rep.AsQueryable().Where(x => x.TenantId == tenantId).AnyAsync(x => x.TypeCategory == category && x.TypeCode == code && x.Id != id))
         {
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.QualityBaseTypeDuplicate,
                 $"质量基础类型 '{category} / {code}' 已存在");
@@ -78,7 +84,11 @@ public class AdoS0QmsQualityBaseTypesController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
-    public async Task<IActionResult> DeleteAsync(long id) => await DeleteByIdAsync(_rep, id);
+    public async Task<IActionResult> DeleteAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return await DeleteByIdAsync(_rep, id, tenantId);
+    }
 
     private static void ApplyQualityBaseType(AdoS0QmsQualityBaseType entity, AdoS0QmsQualityBaseTypeUpsertDto dto, bool isCreate)
     {
@@ -95,7 +105,6 @@ public class AdoS0QmsQualityBaseTypesController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/raw-whitelists")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsRawWhitelistsController : ControllerBase
 {
@@ -109,8 +118,9 @@ public class AdoS0QmsRawWhitelistsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.SupplierCode != null && x.SupplierCode.Contains(q.Keyword!)) ||
                 (x.SupplierName != null && x.SupplierName.Contains(q.Keyword!)) ||
@@ -122,12 +132,17 @@ public class AdoS0QmsRawWhitelistsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    public async Task<IActionResult> GetAsync(long id) => OkOrNotFound(await _rep.GetByIdAsync(id));
+    public async Task<IActionResult> GetAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return OkOrNotFound(await _rep.ByIdScopedByColumnAsync(id, tenantId));
+    }
 
     [HttpGet("options")]
     public async Task<IActionResult> GetOptionsAsync()
     {
-        var list = await _rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current())
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var list = await _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .OrderBy(x => x.SupplierCode)
             .Select(x => new AdoS0QualitySimpleOptionDto { Value = x.Id, Label = (x.SupplierCode ?? "") + " / " + (x.SupplierName ?? "") })
             .ToListAsync();
@@ -137,6 +152,7 @@ public class AdoS0QmsRawWhitelistsController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsRawWhitelistUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var err = ValidateRawWhitelist(dto, out var dimensionType);
         if (err != null) return AdoS0ApiErrors.InvalidRequest(err);
         var entity = new AdoS0QmsRawWhitelist
@@ -147,7 +163,7 @@ public class AdoS0QmsRawWhitelistsController : ControllerBase
             MaterialName = NullIfWhiteSpace(dto.MaterialName),
             DimensionType = dimensionType
         };
-        entity.TenantId = QmsTenantScope.Current();
+        entity.TenantId = tenantId;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
         return Ok(entity);
     }
@@ -155,7 +171,8 @@ public class AdoS0QmsRawWhitelistsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsRawWhitelistUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (entity == null) return NotFound();
         var err = ValidateRawWhitelist(dto, out var dimensionType);
         if (err != null) return AdoS0ApiErrors.InvalidRequest(err);
@@ -169,7 +186,11 @@ public class AdoS0QmsRawWhitelistsController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
-    public async Task<IActionResult> DeleteAsync(long id) => await DeleteByIdAsync(_rep, id);
+    public async Task<IActionResult> DeleteAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return await DeleteByIdAsync(_rep, id, tenantId);
+    }
 
     private static string? ValidateRawWhitelist(AdoS0QmsRawWhitelistUpsertDto dto, out string dimensionType)
     {
@@ -196,7 +217,6 @@ public class AdoS0QmsRawWhitelistsController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/sampling-schemes")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsSamplingSchemesController : ControllerBase
 {
@@ -210,8 +230,9 @@ public class AdoS0QmsSamplingSchemesController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.Number != null && x.Number.Contains(q.Keyword!)) ||
                 (x.Name != null && x.Name.Contains(q.Keyword!)));
@@ -221,17 +242,26 @@ public class AdoS0QmsSamplingSchemesController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    public async Task<IActionResult> GetAsync(long id) => OkOrNotFound(await _rep.GetByIdAsync(id));
+    public async Task<IActionResult> GetAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return OkOrNotFound(await _rep.ByIdScopedByColumnAsync(id, tenantId));
+    }
 
     [HttpGet("options")]
-    public async Task<IActionResult> GetOptionsAsync() => Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current()), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    public async Task<IActionResult> GetOptionsAsync()
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == tenantId), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsSamplingSchemeUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var entity = new AdoS0QmsSamplingScheme();
         ApplySamplingScheme(entity, dto, true);
-        entity.TenantId = QmsTenantScope.Current();
+        entity.TenantId = tenantId;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
         return Ok(entity);
     }
@@ -239,7 +269,8 @@ public class AdoS0QmsSamplingSchemesController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsSamplingSchemeUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (entity == null) return NotFound();
         ApplySamplingScheme(entity, dto, false);
         await _rep.AsUpdateable(entity).ExecuteCommandAsync();
@@ -247,7 +278,11 @@ public class AdoS0QmsSamplingSchemesController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
-    public async Task<IActionResult> DeleteAsync(long id) => await DeleteByIdAsync(_rep, id);
+    public async Task<IActionResult> DeleteAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return await DeleteByIdAsync(_rep, id, tenantId);
+    }
 
     private static void ApplySamplingScheme(AdoS0QmsSamplingScheme entity, AdoS0QmsSamplingSchemeUpsertDto dto, bool isCreate)
     {
@@ -271,7 +306,6 @@ public class AdoS0QmsSamplingSchemesController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/instruments")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsInspectionInstrumentsController : ControllerBase
 {
@@ -285,8 +319,9 @@ public class AdoS0QmsInspectionInstrumentsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.Number != null && x.Number.Contains(q.Keyword!)) ||
                 (x.Name != null && x.Name.Contains(q.Keyword!)) ||
@@ -297,17 +332,26 @@ public class AdoS0QmsInspectionInstrumentsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    public async Task<IActionResult> GetAsync(long id) => OkOrNotFound(await _rep.GetByIdAsync(id));
+    public async Task<IActionResult> GetAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return OkOrNotFound(await _rep.ByIdScopedByColumnAsync(id, tenantId));
+    }
 
     [HttpGet("options")]
-    public async Task<IActionResult> GetOptionsAsync() => Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current()), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    public async Task<IActionResult> GetOptionsAsync()
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == tenantId), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsInspectionInstrumentUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var entity = new AdoS0QmsInspectionInstrument();
         ApplyInspectionInstrument(entity, dto, true);
-        entity.TenantId = QmsTenantScope.Current();
+        entity.TenantId = tenantId;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
         return Ok(entity);
     }
@@ -315,7 +359,8 @@ public class AdoS0QmsInspectionInstrumentsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsInspectionInstrumentUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (entity == null) return NotFound();
         ApplyInspectionInstrument(entity, dto, false);
         await _rep.AsUpdateable(entity).ExecuteCommandAsync();
@@ -323,7 +368,11 @@ public class AdoS0QmsInspectionInstrumentsController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
-    public async Task<IActionResult> DeleteAsync(long id) => await DeleteByIdAsync(_rep, id);
+    public async Task<IActionResult> DeleteAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return await DeleteByIdAsync(_rep, id, tenantId);
+    }
 
     private static void ApplyInspectionInstrument(AdoS0QmsInspectionInstrument entity, AdoS0QmsInspectionInstrumentUpsertDto dto, bool isCreate)
     {
@@ -345,7 +394,6 @@ public class AdoS0QmsInspectionInstrumentsController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/inspection-methods")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsInspectionMethodsController : ControllerBase
 {
@@ -359,8 +407,9 @@ public class AdoS0QmsInspectionMethodsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.Number != null && x.Number.Contains(q.Keyword!)) ||
                 (x.Name != null && x.Name.Contains(q.Keyword!)));
@@ -370,17 +419,26 @@ public class AdoS0QmsInspectionMethodsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    public async Task<IActionResult> GetAsync(long id) => OkOrNotFound(await _rep.GetByIdAsync(id));
+    public async Task<IActionResult> GetAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return OkOrNotFound(await _rep.ByIdScopedByColumnAsync(id, tenantId));
+    }
 
     [HttpGet("options")]
-    public async Task<IActionResult> GetOptionsAsync() => Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current()), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    public async Task<IActionResult> GetOptionsAsync()
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == tenantId), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsInspectionMethodUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var entity = new AdoS0QmsInspectionMethod();
         ApplyInspectionMethod(entity, dto, true);
-        entity.TenantId = QmsTenantScope.Current();
+        entity.TenantId = tenantId;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
         return Ok(entity);
     }
@@ -388,7 +446,8 @@ public class AdoS0QmsInspectionMethodsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsInspectionMethodUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (entity == null) return NotFound();
         ApplyInspectionMethod(entity, dto, false);
         await _rep.AsUpdateable(entity).ExecuteCommandAsync();
@@ -396,7 +455,11 @@ public class AdoS0QmsInspectionMethodsController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
-    public async Task<IActionResult> DeleteAsync(long id) => await DeleteByIdAsync(_rep, id);
+    public async Task<IActionResult> DeleteAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return await DeleteByIdAsync(_rep, id, tenantId);
+    }
 
     private static void ApplyInspectionMethod(AdoS0QmsInspectionMethod entity, AdoS0QmsInspectionMethodUpsertDto dto, bool isCreate)
     {
@@ -413,7 +476,6 @@ public class AdoS0QmsInspectionMethodsController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/inspection-items")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsInspectionItemsController : ControllerBase
 {
@@ -427,8 +489,9 @@ public class AdoS0QmsInspectionItemsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.Number != null && x.Number.Contains(q.Keyword!)) ||
                 (x.Name != null && x.Name.Contains(q.Keyword!)));
@@ -438,17 +501,26 @@ public class AdoS0QmsInspectionItemsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    public async Task<IActionResult> GetAsync(long id) => OkOrNotFound(await _rep.GetByIdAsync(id));
+    public async Task<IActionResult> GetAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return OkOrNotFound(await _rep.ByIdScopedByColumnAsync(id, tenantId));
+    }
 
     [HttpGet("options")]
-    public async Task<IActionResult> GetOptionsAsync() => Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current()), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    public async Task<IActionResult> GetOptionsAsync()
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == tenantId), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsInspectionItemUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var entity = new AdoS0QmsInspectionItem();
         ApplyInspectionItem(entity, dto, true);
-        entity.TenantId = QmsTenantScope.Current();
+        entity.TenantId = tenantId;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
         return Ok(entity);
     }
@@ -456,7 +528,8 @@ public class AdoS0QmsInspectionItemsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsInspectionItemUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (entity == null) return NotFound();
         ApplyInspectionItem(entity, dto, false);
         await _rep.AsUpdateable(entity).ExecuteCommandAsync();
@@ -464,7 +537,11 @@ public class AdoS0QmsInspectionItemsController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
-    public async Task<IActionResult> DeleteAsync(long id) => await DeleteByIdAsync(_rep, id);
+    public async Task<IActionResult> DeleteAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return await DeleteByIdAsync(_rep, id, tenantId);
+    }
 
     private static void ApplyInspectionItem(AdoS0QmsInspectionItem entity, AdoS0QmsInspectionItemUpsertDto dto, bool isCreate)
     {
@@ -486,7 +563,6 @@ public class AdoS0QmsInspectionItemsController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/inspection-frequencies")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsInspectionFrequenciesController : ControllerBase
 {
@@ -500,8 +576,9 @@ public class AdoS0QmsInspectionFrequenciesController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.Number != null && x.Number.Contains(q.Keyword!)) ||
                 (x.Name != null && x.Name.Contains(q.Keyword!)));
@@ -511,17 +588,26 @@ public class AdoS0QmsInspectionFrequenciesController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    public async Task<IActionResult> GetAsync(long id) => OkOrNotFound(await _rep.GetByIdAsync(id));
+    public async Task<IActionResult> GetAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return OkOrNotFound(await _rep.ByIdScopedByColumnAsync(id, tenantId));
+    }
 
     [HttpGet("options")]
-    public async Task<IActionResult> GetOptionsAsync() => Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == QmsTenantScope.Current()), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    public async Task<IActionResult> GetOptionsAsync()
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return Ok(await BuildOptionsAsync(_rep.AsQueryable().Where(x => x.TenantId == tenantId), x => x.Id, x => $"{x.Number} / {x.Name}"));
+    }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsInspectionFrequencyUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var entity = new AdoS0QmsInspectionFrequency();
         ApplyInspectionFrequency(entity, dto, true);
-        entity.TenantId = QmsTenantScope.Current();
+        entity.TenantId = tenantId;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
         return Ok(entity);
     }
@@ -529,7 +615,8 @@ public class AdoS0QmsInspectionFrequenciesController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsInspectionFrequencyUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (entity == null) return NotFound();
         ApplyInspectionFrequency(entity, dto, false);
         await _rep.AsUpdateable(entity).ExecuteCommandAsync();
@@ -537,7 +624,11 @@ public class AdoS0QmsInspectionFrequenciesController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
-    public async Task<IActionResult> DeleteAsync(long id) => await DeleteByIdAsync(_rep, id);
+    public async Task<IActionResult> DeleteAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        return await DeleteByIdAsync(_rep, id, tenantId);
+    }
 
     private static void ApplyInspectionFrequency(AdoS0QmsInspectionFrequency entity, AdoS0QmsInspectionFrequencyUpsertDto dto, bool isCreate)
     {
@@ -553,9 +644,10 @@ public class AdoS0QmsInspectionFrequenciesController : ControllerBase
 
 internal static partial class AdoS0QmsControllerHelpers
 {
-    internal static async Task<IActionResult> DeleteByIdAsync<TEntity>(SqlSugarRepository<TEntity> rep, long id) where TEntity : class, new()
+    /// <summary>租户内按主键删除:越租户的 id 一律按 NotFound 处理,不泄露存在性。</summary>
+    internal static async Task<IActionResult> DeleteByIdAsync<TEntity>(SqlSugarRepository<TEntity> rep, long id, long tenantId) where TEntity : class, new()
     {
-        var item = await rep.GetByIdAsync(id);
+        var item = await rep.ByIdScopedByColumnAsync(id, tenantId);
         if (item == null) return new NotFoundResult();
         await rep.DeleteAsync(item);
         return new OkObjectResult(new { message = "删除成功" });

+ 0 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityDictionaryController.cs

@@ -2,7 +2,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
 
 [ApiController]
 [Route("api/s0/quality/dictionary")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsDictionaryController : ControllerBase
 {

+ 26 - 20
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityFqcOqcControllers.cs

@@ -7,7 +7,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
 
 [ApiController]
 [Route("api/s0/quality/fqc-inspection-specs")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
 {
@@ -25,8 +24,9 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.FileNumber != null && x.FileNumber.Contains(q.Keyword!))
                 || (x.ApplicableModel != null && x.ApplicableModel.Contains(q.Keyword!))
@@ -39,7 +39,8 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetDetailAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var items = await _entryRep.AsQueryable().Where(x => x.MasterId == id).OrderBy(x => x.Id).ToListAsync();
         return Ok(new { master, items });
@@ -48,15 +49,16 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsFqcInspectionSpecUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
         {
             var master = new AdoS0QmsFqcInspectionSpec();
             ApplyFqcSpec(master, dto);
-            master.TenantId = QmsTenantScope.Current();
+            master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncFqcEntriesAsync(master.Id, dto.Items);
+            await SyncFqcEntriesAsync(master.Id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -70,7 +72,8 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsFqcInspectionSpecUpsertDto dto)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -79,7 +82,7 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
             ApplyFqcSpec(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncFqcEntriesAsync(id, dto.Items);
+            await SyncFqcEntriesAsync(id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -93,7 +96,8 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -123,9 +127,8 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
         entity.Version = dto.Version;
     }
 
-    private async Task SyncFqcEntriesAsync(long masterId, IEnumerable<AdoS0QmsFqcInspectionSpecEntryDto> items)
+    private async Task SyncFqcEntriesAsync(long masterId, IEnumerable<AdoS0QmsFqcInspectionSpecEntryDto> items, long tenantId)
     {
-        var tenantId = QmsTenantScope.Current();
         foreach (var item in items)
         {
             var entity = new AdoS0QmsFqcInspectionSpecEntry
@@ -151,7 +154,6 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
 
 [ApiController]
 [Route("api/s0/quality/oqc-inspection-specs")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
 {
@@ -169,8 +171,9 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto 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 == QmsTenantScope.Current())
+        var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.FileNumber != null && x.FileNumber.Contains(q.Keyword!))
                 || (x.ApplicableModel != null && x.ApplicableModel.Contains(q.Keyword!))
@@ -183,7 +186,8 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetDetailAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var items = await _entryRep.AsQueryable().Where(x => x.MasterId == id).OrderBy(x => x.Id).ToListAsync();
         return Ok(new { master, items });
@@ -192,15 +196,16 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsOqcInspectionSpecUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
         {
             var master = new AdoS0QmsOqcInspectionSpec();
             ApplyOqcSpec(master, dto);
-            master.TenantId = QmsTenantScope.Current();
+            master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncOqcEntriesAsync(master.Id, dto.Items);
+            await SyncOqcEntriesAsync(master.Id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -214,7 +219,8 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsOqcInspectionSpecUpsertDto dto)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -223,7 +229,7 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
             ApplyOqcSpec(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncOqcEntriesAsync(id, dto.Items);
+            await SyncOqcEntriesAsync(id, dto.Items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -237,7 +243,8 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        var master = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -267,9 +274,8 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
         entity.Version = dto.Version;
     }
 
-    private async Task SyncOqcEntriesAsync(long masterId, IEnumerable<AdoS0QmsOqcInspectionSpecEntryDto> items)
+    private async Task SyncOqcEntriesAsync(long masterId, IEnumerable<AdoS0QmsOqcInspectionSpecEntryDto> items, long tenantId)
     {
-        var tenantId = QmsTenantScope.Current();
         foreach (var item in items)
         {
             var entity = new AdoS0QmsOqcInspectionSpecEntry

+ 14 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0CustomersController.cs

@@ -9,7 +9,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Sales;
 /// </summary>
 [ApiController]
 [Route("api/s0/sales/customers")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0CustomersController : ControllerBase
 {
@@ -23,9 +22,10 @@ public class AdoS0CustomersController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0CustMasterQueryDto 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()
+        var query = _rep.ScopedTo(tenantId)
             .WhereIF(q.CompanyRefId.HasValue, x => x.CompanyRefId == q.CompanyRefId.Value)
             .WhereIF(q.FactoryRefId.HasValue, x => x.FactoryRefId == q.FactoryRefId.Value)
             .WhereIF(!string.IsNullOrWhiteSpace(q.DomainCode), x => x.DomainCode == q.DomainCode)
@@ -55,19 +55,22 @@ public class AdoS0CustomersController : 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.ByIdScopedAsync(id, tenantId);
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0CustMasterUpsertDto dto)
     {
-        if (await _rep.IsAnyAsync(x => x.FactoryRefId == dto.FactoryRefId && x.Cust == dto.Cust))
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.Cust == dto.Cust))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.CustomerCodeExists, "客户编码已存在");
 
         var now = DateTime.Now;
         var entity = new AdoS0CustMaster
         {
+            TenantId = tenantId,
             CompanyRefId = dto.CompanyRefId,
             FactoryRefId = dto.FactoryRefId,
             DomainCode = dto.DomainCode,
@@ -103,10 +106,11 @@ public class AdoS0CustomersController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0CustMasterUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
-        if (await _rep.IsAnyAsync(x => x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Cust == dto.Cust))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Cust == dto.Cust))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.CustomerCodeExists, "客户编码已存在");
 
         entity.CompanyRefId = dto.CompanyRefId;
@@ -141,7 +145,8 @@ public class AdoS0CustomersController : ControllerBase
     [HttpPatch("{id:long}/toggle-enabled")]
     public async Task<IActionResult> ToggleActiveAsync(long id, [FromBody] AdoS0CustMasterToggleActiveDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         entity.IsActive = dto.IsActive;
@@ -154,7 +159,8 @@ public class AdoS0CustomersController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
         await _rep.DeleteAsync(item);

+ 0 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0KittingExcludeRuleController.cs

@@ -11,7 +11,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Sales;
 /// </summary>
 [ApiController]
 [Route("api/s0/sales/kitting-exclude-rule")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0KittingExcludeRuleController : ControllerBase
 {

+ 16 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0MaterialsController.cs

@@ -9,7 +9,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Sales;
 /// </summary>
 [ApiController]
 [Route("api/s0/sales/materials")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MaterialsController : ControllerBase
 {
@@ -25,9 +24,10 @@ public class AdoS0MaterialsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0ItemMasterQueryDto 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()
+        var query = _rep.ScopedTo(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)
@@ -60,7 +60,8 @@ public class AdoS0MaterialsController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
         item.LocationDescr = FormatLocationDescr(item.Location, null);
         return Ok(item);
@@ -69,7 +70,9 @@ public class AdoS0MaterialsController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ItemMasterUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (await _rep.IsAnyAsync(x =>
+                x.TenantId == tenantId &&
                 x.CompanyRefId == dto.CompanyRefId &&
                 x.FactoryRefId == dto.FactoryRefId &&
                 x.ItemNum == dto.ItemNum))
@@ -79,6 +82,8 @@ public class AdoS0MaterialsController : ControllerBase
 
         var now = DateTime.Now;
         var entity = MapDtoToNewEntity(dto, now);
+        // 租户只由后端可信上下文赋值,忽略 DTO 中任何 tenant 相关入参。
+        entity.TenantId = tenantId;
         entity.Status = ResolveStatus(dto);
 
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
@@ -88,10 +93,12 @@ public class AdoS0MaterialsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ItemMasterUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         if (await _rep.IsAnyAsync(x =>
+                x.TenantId == tenantId &&
                 x.Id != id &&
                 x.CompanyRefId == dto.CompanyRefId &&
                 x.FactoryRefId == dto.FactoryRefId &&
@@ -111,7 +118,8 @@ public class AdoS0MaterialsController : ControllerBase
     [HttpPatch("{id:long}/toggle-enabled")]
     public async Task<IActionResult> ToggleActiveAsync(long id, [FromBody] AdoS0ItemMasterToggleActiveDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         entity.IsActive = dto.IsActive;
@@ -125,10 +133,11 @@ public class AdoS0MaterialsController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
-        var refInfo = await _refChecker.MaterialReferencesAsync(item.ItemNum);
+        var refInfo = await _refChecker.MaterialReferencesAsync(tenantId, item.ItemNum);
         if (refInfo is { } r)
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
                 $"存在 {r.Count} 条 {r.Table} 引用该物料,无法删除");

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0SubstituteSchemesController.cs

@@ -9,7 +9,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Sales;
 /// </summary>
 [ApiController]
 [Route("api/s0/sales/substitute-schemes")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0SubstituteSchemesController : ControllerBase
 {
@@ -25,9 +24,10 @@ public class AdoS0SubstituteSchemesController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0SubstituteSchemeQueryDto 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()
+        var query = _rep.ScopedTo(tenantId)
             .WhereIF(q.CompanyId.HasValue, x => x.CompanyId == q.CompanyId.Value)
             .WhereIF(q.FactoryId.HasValue, x => x.FactoryId == q.FactoryId.Value)
             .WhereIF(!string.IsNullOrWhiteSpace(q.SubstituteCode),
@@ -44,6 +44,7 @@ public class AdoS0SubstituteSchemesController : ControllerBase
         // 源平台:固定空白记录 UNION;这里在查询层拼装,避免落库。
         var blank = new IcSubstitute
         {
+            TenantId = tenantId,
             Id = BlankRowId,
             SubstituteCode = string.Empty,
             SubstituteStrategy = null,

+ 23 - 17
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SrmPurchasesController.cs

@@ -10,7 +10,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Supply;
 /// </summary>
 [ApiController]
 [Route("api/s0/supply/srm-purchases")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0SrmPurchasesController : ControllerBase
 {
@@ -31,9 +30,10 @@ public class AdoS0SrmPurchasesController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0SrmPurchaseQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
 
-        var baseQuery = _rep.AsQueryable()
+        var baseQuery = _rep.ScopedTo(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,7 +48,7 @@ public class AdoS0SrmPurchasesController : ControllerBase
         if (!string.IsNullOrWhiteSpace(q.Keyword))
         {
             var kw = q.Keyword!;
-            var itemIds = await _itemRep.AsQueryable()
+            var itemIds = await _itemRep.ScopedTo(tenantId)
                 .Where(it =>
                     it.ItemNum.Contains(kw) ||
                     it.Descr.Contains(kw) ||
@@ -70,7 +70,7 @@ public class AdoS0SrmPurchasesController : ControllerBase
             .Take(q.PageSize)
             .ToListAsync();
 
-        await ApplyItemAndSupplierDisplayAsync(list);
+        await ApplyItemAndSupplierDisplayAsync(list, tenantId);
 
         var dtoList = list.Select(AdoS0SrmPurchaseDto.FromEntity).ToList();
         return Ok(new { total, page = q.Page, pageSize = q.PageSize, list = dtoList });
@@ -79,53 +79,59 @@ public class AdoS0SrmPurchasesController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
-        await ApplyItemAndSupplierDisplayAsync(new List<AdoS0SrmPurchase> { item });
+        await ApplyItemAndSupplierDisplayAsync(new List<AdoS0SrmPurchase> { item }, tenantId);
         return Ok(AdoS0SrmPurchaseDto.FromEntity(item));
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0SrmPurchaseUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var dateErr = ValidateDateRange(dto.EffectiveDate, dto.ExpiringDate);
         if (dateErr != null) return AdoS0ApiErrors.InvalidRequest(dateErr);
 
-        var refErr = await ValidateReferencesAsync(dto.IcitemId, dto.SupplierId);
+        var refErr = await ValidateReferencesAsync(dto.IcitemId, dto.SupplierId, tenantId);
         if (refErr != null) return refErr;
 
         var now = DateTime.Now;
         var entity = MapDtoToEntity(dto, new AdoS0SrmPurchase(), now, isNew: true);
+        // 租户只由后端可信上下文赋值,忽略 DTO 中任何 tenant 相关入参。
+        entity.TenantId = tenantId;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
 
-        await ApplyItemAndSupplierDisplayAsync(new List<AdoS0SrmPurchase> { entity });
+        await ApplyItemAndSupplierDisplayAsync(new List<AdoS0SrmPurchase> { entity }, tenantId);
         return Ok(AdoS0SrmPurchaseDto.FromEntity(entity));
     }
 
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0SrmPurchaseUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         var dateErr = ValidateDateRange(dto.EffectiveDate, dto.ExpiringDate);
         if (dateErr != null) return AdoS0ApiErrors.InvalidRequest(dateErr);
 
-        var refErr = await ValidateReferencesAsync(dto.IcitemId, dto.SupplierId);
+        var refErr = await ValidateReferencesAsync(dto.IcitemId, dto.SupplierId, tenantId);
         if (refErr != null) return refErr;
 
         MapDtoToEntity(dto, entity, DateTime.Now, isNew: false);
         await _rep.AsUpdateable(entity).ExecuteCommandAsync();
 
-        await ApplyItemAndSupplierDisplayAsync(new List<AdoS0SrmPurchase> { entity });
+        await ApplyItemAndSupplierDisplayAsync(new List<AdoS0SrmPurchase> { entity }, tenantId);
         return Ok(AdoS0SrmPurchaseDto.FromEntity(entity));
     }
 
     [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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
         await _rep.DeleteAsync(item);
@@ -139,12 +145,12 @@ public class AdoS0SrmPurchasesController : ControllerBase
         return null;
     }
 
-    private async Task<IActionResult?> ValidateReferencesAsync(long icitemId, long supplierId)
+    private async Task<IActionResult?> ValidateReferencesAsync(long icitemId, long supplierId, long tenantId)
     {
-        if (!await _itemRep.IsAnyAsync(x => x.Id == icitemId))
+        if (!await _itemRep.IsAnyAsync(x => x.TenantId == tenantId && x.Id == icitemId))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.MaterialReferenceInvalid, "物料主数据引用无效");
 
-        if (!await _suppRep.IsAnyAsync(x => x.Id == supplierId))
+        if (!await _suppRep.IsAnyAsync(x => x.TenantId == tenantId && x.Id == supplierId))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.InvalidReference, "供应商主数据引用无效");
 
         return null;
@@ -195,7 +201,7 @@ public class AdoS0SrmPurchasesController : ControllerBase
         return entity;
     }
 
-    private async Task ApplyItemAndSupplierDisplayAsync(List<AdoS0SrmPurchase> rows)
+    private async Task ApplyItemAndSupplierDisplayAsync(List<AdoS0SrmPurchase> rows, long tenantId)
     {
         if (rows.Count == 0) return;
 
@@ -208,7 +214,7 @@ public class AdoS0SrmPurchasesController : ControllerBase
             .ToList();
         var items = numbers.Count == 0
             ? new List<AdoS0ItemMaster>()
-            : await _itemRep.AsQueryable().Where(it => numbers.Contains(it.ItemNum)).ToListAsync();
+            : await _itemRep.ScopedTo(tenantId).Where(it => numbers.Contains(it.ItemNum)).ToListAsync();
 
         // MySQL 默认 ci 排序,用大小写不敏感字典对齐旧 SQL 的 number=ItemNum 关联语义。
         var itemByNum = new Dictionary<string, AdoS0ItemMaster>(StringComparer.OrdinalIgnoreCase);

+ 19 - 13
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SuppliersController.cs

@@ -10,7 +10,6 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Supply;
 /// </summary>
 [ApiController]
 [Route("api/s0/supply/suppliers")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0SuppliersController : ControllerBase
 {
@@ -31,9 +30,10 @@ public class AdoS0SuppliersController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0SuppMasterQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
 
-        var baseQuery = _rep.AsQueryable()
+        var baseQuery = _rep.ScopedTo(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)
@@ -66,7 +66,7 @@ public class AdoS0SuppliersController : ControllerBase
             .Take(q.PageSize)
             .ToListAsync();
 
-        await ApplyLocationNamesAsync(list);
+        await ApplyLocationNamesAsync(list, tenantId);
 
         foreach (var s in list)
         {
@@ -82,10 +82,11 @@ public class AdoS0SuppliersController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
-        await ApplyLocationNamesAsync(new List<AdoS0SuppMaster> { item });
+        await ApplyLocationNamesAsync(new List<AdoS0SuppMaster> { item }, tenantId);
         item.APStartDay1Text = FormatApDay(item.APStartDay1);
         item.APEndDay1Text = FormatApDay(item.APEndDay1);
         item.APStartDay2Text = FormatApDay(item.APStartDay2);
@@ -97,12 +98,14 @@ public class AdoS0SuppliersController : ControllerBase
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0SuppMasterUpsertDto dto)
     {
-        if (await _rep.IsAnyAsync(x => x.FactoryRefId == dto.FactoryRefId && x.Supp == dto.Supp))
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.Supp == dto.Supp))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.SupplierCodeExists, "供应商编码已存在");
 
         var now = DateTime.Now;
         var entity = new AdoS0SuppMaster
         {
+            TenantId = tenantId,
             CompanyRefId = dto.CompanyRefId,
             FactoryRefId = dto.FactoryRefId,
             DomainCode = dto.DomainCode,
@@ -138,10 +141,11 @@ public class AdoS0SuppliersController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0SuppMasterUpsertDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
-        if (await _rep.IsAnyAsync(x => x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Supp == dto.Supp))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Supp == dto.Supp))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.SupplierCodeExists, "供应商编码已存在");
 
         entity.CompanyRefId = dto.CompanyRefId;
@@ -176,7 +180,8 @@ public class AdoS0SuppliersController : ControllerBase
     [HttpPatch("{id:long}/toggle-enabled")]
     public async Task<IActionResult> ToggleActiveAsync(long id, [FromBody] AdoS0SuppMasterToggleActiveDto dto)
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
 
         entity.IsActive = dto.IsActive;
@@ -189,10 +194,11 @@ public class AdoS0SuppliersController : 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.ByIdScopedAsync(id, tenantId);
         if (item == null) return NotFound();
 
-        var refInfo = await _refChecker.SupplierReferencesAsync(item.Supp);
+        var refInfo = await _refChecker.SupplierReferencesAsync(tenantId, item.Supp);
         if (refInfo is { } r)
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
                 $"存在 {r.Count} 条 {r.Table} 引用该供应商,无法删除");
@@ -204,7 +210,7 @@ public class AdoS0SuppliersController : ControllerBase
     /// <summary>
     /// 按 Domain + PurContact 批量匹配库位主数据,填充 LocationName(与源平台 left join LocationMaster 一致)。
     /// </summary>
-    private async Task ApplyLocationNamesAsync(List<AdoS0SuppMaster> suppliers)
+    private async Task ApplyLocationNamesAsync(List<AdoS0SuppMaster> suppliers, long tenantId)
     {
         if (suppliers.Count == 0) return;
 
@@ -221,7 +227,7 @@ public class AdoS0SuppliersController : ControllerBase
             return;
         }
 
-        var locRows = await _locRep.AsQueryable()
+        var locRows = await _locRep.ScopedTo(tenantId)
             .Where(l => domainCodes.Contains(l.DomainCode))
             .ToListAsync();
 

+ 36 - 33
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0ReferenceChecker.cs

@@ -58,11 +58,11 @@ public sealed class AdoS0ReferenceChecker
     /// 仅校验"目标主表存在该 itemNum",不做 factoryRefId/domain 作用域校验(B2 归口)。
     /// 空值视为"未填"直接放行,由调用方先做必填校验。
     /// </summary>
-    public async Task<bool> MaterialExistsAsync(string? itemNum)
+    public async Task<bool> MaterialExistsAsync(long tenantId, string? itemNum)
     {
         if (string.IsNullOrWhiteSpace(itemNum)) return true;
         return await _db.Queryable<AdoS0ItemMaster>()
-            .Where(x => x.ItemNum == itemNum)
+            .Where(x => x.TenantId == tenantId && x.ItemNum == itemNum)
             .AnyAsync();
     }
 
@@ -76,12 +76,12 @@ public sealed class AdoS0ReferenceChecker
     /// 0/0 边界:目标 ItemMaster 的 CompanyRefId=0 且 FactoryRefId=0 被视为"未 scope 化的历史数据",
     /// 仅当来源 Routing 同为 0/0 时才允许匹配;否则判 ScopeMiss(不做全局物料白名单)。
     /// </summary>
-    public async Task<MaterialScopeCheck> MaterialExistsInScopeAsync(string? itemNum, long companyRefId, long factoryRefId)
+    public async Task<MaterialScopeCheck> MaterialExistsInScopeAsync(long tenantId, string? itemNum, long companyRefId, long factoryRefId)
     {
         if (string.IsNullOrWhiteSpace(itemNum)) return MaterialScopeCheck.Match;
 
         var match = await _db.Queryable<AdoS0ItemMaster>()
-            .Where(x => x.ItemNum == itemNum)
+            .Where(x => x.TenantId == tenantId && x.ItemNum == itemNum)
             .Select(x => new { x.CompanyRefId, x.FactoryRefId })
             .ToListAsync();
 
@@ -99,11 +99,11 @@ public sealed class AdoS0ReferenceChecker
     /// 用于货架等"从属于 LocationMaster 聚合"的子实体写入保护。
     /// 空值视为"未填"直接放行;作用域(Company/Factory/Domain)一致性归 B2。
     /// </summary>
-    public async Task<bool> LocationExistsAsync(string? location)
+    public async Task<bool> LocationExistsAsync(long tenantId, string? location)
     {
         if (string.IsNullOrWhiteSpace(location)) return true;
         return await _db.Queryable<AdoS0LocationMaster>()
-            .Where(x => x.Location == location)
+            .Where(x => x.TenantId == tenantId && x.Location == location)
             .AnyAsync();
     }
 
@@ -115,12 +115,12 @@ public sealed class AdoS0ReferenceChecker
     ///   存在且 Company/Factory 完全匹配 → Match(含双方同为 0/0 的历史)
     ///   存在但 scope 不匹配 → ScopeMiss
     /// </summary>
-    public async Task<MaterialScopeCheck> LocationExistsInScopeAsync(string? location, long companyRefId, long factoryRefId)
+    public async Task<MaterialScopeCheck> LocationExistsInScopeAsync(long tenantId, string? location, long companyRefId, long factoryRefId)
     {
         if (string.IsNullOrWhiteSpace(location)) return MaterialScopeCheck.Match;
 
         var match = await _db.Queryable<AdoS0LocationMaster>()
-            .Where(x => x.Location == location)
+            .Where(x => x.TenantId == tenantId && x.Location == location)
             .Select(x => new { x.CompanyRefId, x.FactoryRefId })
             .ToListAsync();
 
@@ -138,11 +138,11 @@ public sealed class AdoS0ReferenceChecker
     /// 注意:历史数据中存在大量 Department 未对齐到 DepartmentMaster 的情况,
     /// 本方法只拦截新写入,不影响历史记录读取。
     /// </summary>
-    public async Task<bool> DepartmentExistsAsync(string? department)
+    public async Task<bool> DepartmentExistsAsync(long tenantId, string? department)
     {
         if (string.IsNullOrWhiteSpace(department)) return true;
         return await _db.Queryable<AdoS0DepartmentMaster>()
-            .Where(x => x.Department == department)
+            .Where(x => x.TenantId == tenantId && x.Department == department)
             .AnyAsync();
     }
 
@@ -150,12 +150,12 @@ public sealed class AdoS0ReferenceChecker
     /// B2 保存端:部门(DepartmentMaster)存在 + 作用域一致性检查。
     /// 语义与 <see cref="MaterialExistsInScopeAsync"/> 对齐;只拦截新写入,不影响历史读取。
     /// </summary>
-    public async Task<MaterialScopeCheck> DepartmentExistsInScopeAsync(string? department, long companyRefId, long factoryRefId)
+    public async Task<MaterialScopeCheck> DepartmentExistsInScopeAsync(long tenantId, string? department, long companyRefId, long factoryRefId)
     {
         if (string.IsNullOrWhiteSpace(department)) return MaterialScopeCheck.Match;
 
         var match = await _db.Queryable<AdoS0DepartmentMaster>()
-            .Where(x => x.Department == department)
+            .Where(x => x.TenantId == tenantId && x.Department == department)
             .Select(x => new { x.CompanyRefId, x.FactoryRefId })
             .ToListAsync();
 
@@ -172,11 +172,11 @@ public sealed class AdoS0ReferenceChecker
     /// 注意跨命名:业务键是 WorkCtrMaster.WorkCtr(非 WorkCenterCode)。
     /// 空值视为"未填"直接放行;作用域(Domain/Factory)一致性归 B2。
     /// </summary>
-    public async Task<bool> WorkCenterExistsAsync(string? workCenterCode)
+    public async Task<bool> WorkCenterExistsAsync(long tenantId, string? workCenterCode)
     {
         if (string.IsNullOrWhiteSpace(workCenterCode)) return true;
         return await _db.Queryable<AdoS0WorkCtrMaster>()
-            .Where(x => x.WorkCtr == workCenterCode)
+            .Where(x => x.TenantId == tenantId && x.WorkCtr == workCenterCode)
             .AnyAsync();
     }
 
@@ -184,22 +184,22 @@ public sealed class AdoS0ReferenceChecker
     /// 物料(ItemMaster)删除前引用检查。
     /// 覆盖下游:工艺路线明细 / 物料替代关系 / 货源清单(SRM 采购)。
     /// </summary>
-    public async Task<(int Count, string Table)?> MaterialReferencesAsync(string? itemNum)
+    public async Task<(int Count, string Table)?> MaterialReferencesAsync(long tenantId, string? itemNum)
     {
         if (string.IsNullOrWhiteSpace(itemNum)) return null;
 
         var routing = await _db.Queryable<AdoS0MfgRoutingOpDetail>()
-            .Where(x => x.MaterialCode == itemNum)
+            .Where(x => x.TenantId == tenantId && x.MaterialCode == itemNum)
             .CountAsync();
         if (routing > 0) return (routing, "工艺路线明细 (RoutingOpDetail)");
 
         var sub = await _db.Queryable<AdoS0ItemSubstituteDetail>()
-            .Where(x => x.ItemNum == itemNum || x.SubstituteItem == itemNum)
+            .Where(x => x.TenantId == tenantId && (x.ItemNum == itemNum || x.SubstituteItem == itemNum))
             .CountAsync();
         if (sub > 0) return (sub, "物料替代关系 (ItemSubstituteDetail)");
 
         var lineMat = await _db.Queryable<AdoS0ProdLineDetail>()
-            .Where(x => x.Part == itemNum)
+            .Where(x => x.TenantId == tenantId && x.Part == itemNum)
             .CountAsync();
         if (lineMat > 0) return (lineMat, "线边物料 (ProdLineDetail.Part)");
 
@@ -216,19 +216,19 @@ public sealed class AdoS0ReferenceChecker
     /// 供应商(SuppMaster)删除前引用检查。
     /// 覆盖下游:货源清单(SRM 采购)。
     /// </summary>
-    public async Task<(int Count, string Table)?> SupplierReferencesAsync(string? supp)
+    public async Task<(int Count, string Table)?> SupplierReferencesAsync(long tenantId, string? supp)
     {
         if (string.IsNullOrWhiteSpace(supp)) return null;
 
         // 只用 SupplierNumber(真实列)。原 OR 分支的 x.Supplier 是 [SugarColumn(IsIgnore = true)]
         // 展示字段,进 Where 会抛异常 → SafeCount 兜底 0,实际保护失效(BUG-S0-SRMPURCHASE-SCHEMA-MISMATCH)。
         var srm = await SafeCountAsync(
-            () => _db.Queryable<AdoS0SrmPurchase>().Where(x => x.SupplierNumber == supp),
+            () => _db.Queryable<AdoS0SrmPurchase>().Where(x => x.TenantId == tenantId && x.SupplierNumber == supp),
             "SrmPurchase.SupplierNumber");
         if (srm > 0) return (srm, "货源清单 (SrmPurchase)");
 
         var routSupp = await _db.Queryable<AdoS0MfgRoutingOpDetail>()
-            .Where(x => x.SupplierCode == supp)
+            .Where(x => x.TenantId == tenantId && x.SupplierCode == supp)
             .CountAsync();
         if (routSupp > 0) return (routSupp, "工艺路线明细供应商 (RoutingOpDetail.SupplierCode)");
 
@@ -239,17 +239,17 @@ public sealed class AdoS0ReferenceChecker
     /// 工作中心(WorkCtrMaster)删除前引用检查。
     /// 覆盖下游:工艺路线明细 / 线边物料。
     /// </summary>
-    public async Task<(int Count, string Table)?> WorkCenterReferencesAsync(string? workCtr)
+    public async Task<(int Count, string Table)?> WorkCenterReferencesAsync(long tenantId, string? workCtr)
     {
         if (string.IsNullOrWhiteSpace(workCtr)) return null;
 
         var routing = await _db.Queryable<AdoS0MfgRoutingOpDetail>()
-            .Where(x => x.WorkCenterCode == workCtr)
+            .Where(x => x.TenantId == tenantId && x.WorkCenterCode == workCtr)
             .CountAsync();
         if (routing > 0) return (routing, "工艺路线明细 (RoutingOpDetail.WorkCenterCode)");
 
         var lineSite = await _db.Queryable<AdoS0ProdLineDetail>()
-            .Where(x => x.Site == workCtr)
+            .Where(x => x.TenantId == tenantId && x.Site == workCtr)
             .CountAsync();
         if (lineSite > 0) return (lineSite, "线边物料工作中心 (ProdLineDetail.Site)");
 
@@ -306,7 +306,7 @@ public sealed class AdoS0ReferenceChecker
         if (shelf > 0) return (shelf, "货架 (LocationShelfMaster)");
 
         var item = await _db.Queryable<AdoS0ItemMaster>()
-            .Where(x => x.Location == location)
+            .Where(x => x.TenantId == tenantId && x.Location == location)
             .CountAsync();
         if (item > 0) return (item, "物料默认库位 (ItemMaster.Location)");
 
@@ -314,12 +314,12 @@ public sealed class AdoS0ReferenceChecker
         // B1-9 已在保存端做 existence check,此处与之形成写/删对称。
         // Workshop 不纳入(B1-10 cancellation:无主表的自由文本)。
         var line = await _db.Queryable<AdoS0LineMaster>()
-            .Where(x => x.Location == location
+            .Where(x => x.TenantId == tenantId && (x.Location == location
                      || x.VLocation == location
                      || x.Location2 == location
                      || x.Location3 == location
                      || x.PickingLocation == location
-                     || x.MidLocation == location)
+                     || x.MidLocation == location))
             .CountAsync();
         if (line > 0) return (line, "产线 (LineMaster 的 Location/VLocation/Location2/Location3/PickingLocation/MidLocation)");
 
@@ -368,16 +368,19 @@ public sealed class AdoS0ReferenceChecker
     /// 生产线(LineMaster)删除前引用检查。
     /// 覆盖下游:生产线物料(ProdLineDetail.Line)/ 线体岗位(LinePost.ProductionLineId)。
     /// </summary>
-    public async Task<(int Count, string Table)?> ProductionLineReferencesAsync(long id, string? line)
+    public async Task<(int Count, string Table)?> ProductionLineReferencesAsync(long tenantId, long id, string? line)
     {
         if (!string.IsNullOrWhiteSpace(line))
         {
             var detail = await _db.Queryable<AdoS0ProdLineDetail>()
-                .Where(x => x.Line == line)
+                .Where(x => x.TenantId == tenantId && x.Line == line)
                 .CountAsync();
             if (detail > 0) return (detail, "生产线物料 (ProdLineDetail.Line)");
         }
 
+        // LinePost 无 tenant_id 列、无 ITenantIdFilter、且无任何 API 入口(仅 CodeFirst 建表),
+        // 当前 0 行;此处只做删除保护计数,不承载租户数据读取。补 tenant 需先为该表加列,
+        // 属本轮 P0 控制器收口范围之外,已在回执中列为已知残留。
         var post = await _db.Queryable<AdoS0MfgLinePost>()
             .Where(x => x.ProductionLineId == id)
             .CountAsync();
@@ -406,23 +409,23 @@ public sealed class AdoS0ReferenceChecker
     /// 人员技能(PersonSkill)删除前引用检查。
     /// 覆盖下游:人员技能关系(EmpSkills.SkillNo=Code)/ 产线岗位技能明细(LineSkillDetail.PersonSkillId=Id)/ 人员技能分配(PersonSkillAssignment.SkillId=Id)。
     /// </summary>
-    public async Task<(int Count, string Table)?> PersonSkillReferencesAsync(long id, string? code)
+    public async Task<(int Count, string Table)?> PersonSkillReferencesAsync(long tenantId, long id, string? code)
     {
         if (!string.IsNullOrWhiteSpace(code))
         {
             var emp = await _db.Queryable<AdoS0EmpSkills>()
-                .Where(x => x.SkillNo == code)
+                .Where(x => x.TenantId == tenantId && x.SkillNo == code)
                 .CountAsync();
             if (emp > 0) return (emp, "人员技能关系 (EmpSkills.SkillNo)");
         }
 
         var lineSkill = await _db.Queryable<AdoS0LineSkillDetail>()
-            .Where(x => x.PersonSkillId == id)
+            .Where(x => x.TenantId == tenantId && x.PersonSkillId == id)
             .CountAsync();
         if (lineSkill > 0) return (lineSkill, "产线岗位技能明细 (LineSkillDetail)");
 
         var assign = await _db.Queryable<AdoS0MfgPersonSkillAssignment>()
-            .Where(x => x.SkillId == id)
+            .Where(x => x.TenantId == tenantId && x.SkillId == id)
             .CountAsync();
         if (assign > 0) return (assign, "人员技能分配 (PersonSkillAssignment)");
 

+ 43 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0TenantQuery.cs

@@ -0,0 +1,43 @@
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// S0 租户内数据访问统一入口(S0-P0-TENANT-ID-CLOSURE)。
+///
+/// 铁律:
+///  - tenant_id 是唯一租户安全边界;Domain / DomainCode / CompanyRefId / FactoryRefId
+///    只能作为业务筛选条件,永远不得替代租户条件;
+///  - 租户值只来自 <see cref="AdoS0TenantScope.TryResolveRequired"/>(认证后的 JWT claim),
+///    不接受前端 payload / query / DTO 传入;
+///  - 全局 AOP <c>ITenantIdFilter</c> 只作为 defense-in-depth,Controller 必须显式带租户条件;
+///  - 按主键取单条时,"不存在" 与 "属于其他租户" 一律返回 null,不泄露记录存在性。
+///
+/// qms_* 系列实体未实现 ITenantIdFilter(全局 AOP 对其不生效),故另提供按物理列
+/// <c>tenant_id</c> 收口的重载,供 S0 Quality 控制器使用。
+/// </summary>
+public static class AdoS0TenantQuery
+{
+    /// <summary>租户内查询(实现 ITenantIdFilter 的实体)。后续可继续追加业务筛选条件。</summary>
+    public static ISugarQueryable<T> ScopedTo<T>(this SqlSugarRepository<T> rep, long tenantId)
+        where T : class, ITenantIdFilter, new()
+        => rep.AsQueryable().Where(x => x.TenantId == tenantId);
+
+    /// <summary>租户内按主键取单条;不存在或不属当前租户均返回 null。</summary>
+    public static Task<T> ByIdScopedAsync<T>(this SqlSugarRepository<T> rep, long id, long tenantId)
+        where T : class, ITenantIdFilter, new()
+        => rep.AsQueryable().Where(x => x.TenantId == tenantId).In(id).FirstAsync();
+
+    /// <summary>
+    /// 租户内查询(未实现 ITenantIdFilter、但物理表有 tenant_id 列的实体,如 qms_*)。
+    /// 显式按物理列收口,不依赖全局 AOP。
+    /// </summary>
+    public static ISugarQueryable<T> ScopedToByColumn<T>(this SqlSugarRepository<T> rep, long tenantId)
+        where T : class, new()
+        => rep.AsQueryable().Where("tenant_id = @__tid", new { __tid = tenantId });
+
+    /// <summary>租户内按主键取单条(qms_* 等无 ITenantIdFilter 实体);越租户一律返回 null。</summary>
+    public static Task<T> ByIdScopedByColumnAsync<T>(this SqlSugarRepository<T> rep, long id, long tenantId)
+        where T : class, new()
+        => rep.AsQueryable().Where("tenant_id = @__tid", new { __tid = tenantId }).In(id).FirstAsync();
+}