瀏覽代碼

fix(s0): SOP 维护解除 Domain 耦合 — 可见性与关联一律以 tenant_id + 业务键为准

根因:SOP 维护是 S0 里唯一没做完「去 domain 化」的一条链路,Domain 同时承担了
两个它不该承担的职责,且两者互斥:

1. 列表可见性:Controller 用 `x.Domain == q.Domain` 作过滤,DTO 的 Domain 还是 [Required]。
   前端 resolveDomain(query.factoryRefId) 派生 domain,而 AIDOP 租户没有 Type=501 工厂,
   getDefaultS0OrgScope 回落到 Type=201 公司,导致传给接口的是组织雪花 Id
   797403761152069,与业务表 legacy Domain(8010) 永不相等。
2. 展示列关联:ImageType / LineMaster 的 LeftJoin 都带 `qsi.Domain == xx.Domain`。

于是同一租户内出现死结:Domain 取组织 Id 则列表可见但产线名解析不出;取 8010 则
产线名可解析但整张列表查不到。这是模型问题,不是数据问题。

改动:
- Controller List:删除 `.Where(x => x.Domain == domain)`,可见性只由 ScopedTo(tenantId) 决定。
- ImageType join:`qsi.Domain == it.Domain` → `qsi.TenantId == it.TenantId`。
- LineMaster join:`qsi.Domain == lm.Domain` → `qsi.TenantId == lm.TenantId`。
- RoutingOpDetail join:补 `qsi.TenantId == rd.TenantId`(原本无任何租户条件)。
- Query DTO:Domain 由 [Required] string 放宽为 string?,仅保留为兼容字段,不参与可见性判定
  (与 AdoS0MfgSopFileTypeQueryDto 的去 domain 化范式一致)。
- 前端 SopMaintenanceList:列表不再派生并传 domain;新增/编辑的 domain 归属逻辑保持不变。

Create/Update 未改语义:Domain 继续按原样存储,TenantId 仍由后端可信上下文写入。
未做 Domain migration,未改 8010/2410,未动任何既有数据。

防回归:S0ControllerTenantClosureTests 新增两条不变量——
- S0Controllers_JoinsMustBeTenantBound:S0 内所有 Left/Inner/Right/FullJoin 的关联条件
  必须含 TenantId,且不得出现 Domain/DomainCode;
- SopDocuments_ListMustNotGateOnDomain:SOP 列表不得回退成 Domain 等值过滤,
  且 Query DTO 的 Domain 不得再是必填。

验证(现有 3 条 SOP + 4 条 ImageType 数据一行未改、domain 仍为 797403761152069):
后端 build 0 Error;测试 941 通过 / 2 跳过 / 0 失败;vue-tsc 575 ≤ 基线 597 且改动文件净增 0;
最新 master 启动正常(1.0.392,无待执行 migration)。
AIDOPDemo:SOP 文档 total=3,lineDisplay 恢复为「ZJ01 机组装线 / ZJ02 拷机线」,
opDisplay、imgType 均正常解析;传 domain=8010 仍返回 3 条,证明 Domain 不再作可见性门。
UATAdminA:不传 domain、传 797403761152069、传 8010 三种情况 total 均为 0。

chore: bump version server 1.0.392 / Web 2.4.323
YY968XX 1 小時之前
父節點
當前提交
9feb49e0ca

+ 1 - 1
Web/package.json

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

+ 4 - 9
Web/src/views/aidop/s0/manufacturing/SopMaintenanceList.vue

@@ -136,8 +136,10 @@ const rules: FormRules = {
 	itemNum: [{ required: true, message: '请填写物料编码', trigger: 'blur' }],
 };
 
-// 列表按工厂域(domain)查询:接口必传 Domain。查询栏不暴露工厂选择,
-// 统一由系统按 S0 示范组织默认化(与新增/编辑归属同口径,复用 getDefaultS0OrgScope)。
+// 去 domain 化(S0-SOP-DOMAIN-DECOUPLING-FIX):列表不再派生 domain 作为查询条件。
+// 原实现在无 Type=501 工厂时会把公司雪花 Id 当 domain 传给接口,与业务表 legacy Domain 永不相等,
+// 导致本租户数据被挡在列表外。租户边界由后端 tenant_id 保证,前端无需再传。
+// 仍保留默认公司/工厂:新增/编辑弹窗的归属默认化依赖它。
 function applyDefaultQueryScope() {
 	const d = getDefaultS0OrgScope(companyOptions.value, factoryOptions.value);
 	query.companyRefId = d.companyRefId;
@@ -147,16 +149,9 @@ function applyDefaultQueryScope() {
 async function loadList() {
 	loading.value = true;
 	try {
-		const domain = resolveDomain(query.factoryRefId);
-		if (!domain) {
-			rows.value = [];
-			total.value = 0;
-			return;
-		}
 		const data = await s0MfgSopDocumentsApi.list({
 			page: query.page,
 			pageSize: query.pageSize,
-			domain,
 			keyword: query.keyword || undefined,
 		});
 		rows.value = data.list as Row[];

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

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

@@ -125,6 +125,54 @@ public class S0ControllerTenantClosureTests
             + "Create 的租户必须由后端可信上下文赋值,不得依赖全局 AOP 兜底。");
     }
 
+    /// <summary>
+    /// 跨表关联必须 tenant-bound(S0-SOP-DOMAIN-DECOUPLING-FIX)。
+    /// Domain / DomainCode 是 legacy 兼容字段,同租户内不同表取值可能不一致,
+    /// 用它做 join 条件既挡不住跨租户、又会让展示列解析不出来。
+    /// </summary>
+    [Theory]
+    [MemberData(nameof(S0ControllerFiles))]
+    public void S0Controllers_JoinsMustBeTenantBound(string name, string path)
+    {
+        var src = File.ReadAllText(path);
+        foreach (Match m in Regex.Matches(src, @"\.(?:Left|Inner|Right|Full)Join<[^>]+>\(\([^)]*\)\s*=>\s*(?<cond>[^\r\n]+)"))
+        {
+            var cond = m.Groups["cond"].Value;
+            Assert.True(cond.Contains("TenantId", StringComparison.Ordinal),
+                $"{name}: 关联条件缺少 TenantId —— {cond.Trim()};"
+                + "S0 跨表关联必须以 tenant_id + 业务键为准。");
+            Assert.False(Regex.IsMatch(cond, @"\.Domain\s*==|\.DomainCode\s*=="),
+                $"{name}: 关联条件里出现 Domain/DomainCode —— {cond.Trim()};"
+                + "Domain 不得参与跨表关联,同租户内各表 Domain 取值可能不一致。");
+        }
+    }
+
+    /// <summary>
+    /// SOP 文档列表的可见性只能由 tenant_id 决定,不得回退成 Domain 等值过滤。
+    /// </summary>
+    [Fact]
+    public void SopDocuments_ListMustNotGateOnDomain()
+    {
+        var path = Path.Combine(ControllersRoot(), "Manufacturing", "AdoS0MfgSopDocumentsController.cs");
+        Assert.True(File.Exists(path), "AdoS0MfgSopDocumentsController.cs 不存在");
+        var src = File.ReadAllText(path);
+
+        Assert.DoesNotContain(".Where(x => x.Domain == domain)", src);
+        Assert.DoesNotContain("q.Domain.Trim()", src);
+        Assert.Contains("ScopedTo(tenantId)", src);
+
+        // Query DTO 的 Domain 不得再是必填
+        var dto = Path.Combine(new DirectoryInfo(ControllersRoot()).Parent!.Parent!.FullName,
+            "Dto", "S0", "Manufacturing", "AdoS0ManufacturingDtos.cs");
+        var dtoSrc = File.ReadAllText(dto);
+        var block = Regex.Match(dtoSrc, @"class AdoS0MfgSopDocumentQueryDto[\s\S]*?\n\}").Value;
+        Assert.NotEqual(string.Empty, block);
+        // 只看实际代码,剔除注释行(注释里可能提到 [Required] 说明改动原因)
+        var code = string.Join('\n', block.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal)));
+        Assert.DoesNotContain("[Required", code);
+        Assert.Contains("public string? Domain", code);
+    }
+
     /// <summary>可信租户解析器本身不得被前端入参污染。</summary>
     [Fact]
     public void TenantScope_MustNotAcceptClientSuppliedTenant()

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

@@ -24,10 +24,10 @@ public class AdoS0MfgSopDocumentsController : ControllerBase
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
-        var domain = q.Domain.Trim();
-
+        // 去 domain 化(S0-SOP-DOMAIN-DECOUPLING-FIX):列表可见性只由 tenant_id 决定。
+        // 原先 `x.Domain == q.Domain` 会把本租户数据挡在外面——前端在无 Type=501 工厂时
+        // 会把公司雪花 Id 当 Domain 传入,与业务表的 legacy Domain(8010) 永不相等。
         var baseQuery = _rep.ScopedTo(tenantId)
-            .Where(x => x.Domain == domain)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
                 x => (x.Line != null && x.Line.Contains(q.Keyword!))
                     || (x.ItemNum != null && x.ItemNum.Contains(q.Keyword!))
@@ -38,11 +38,14 @@ public class AdoS0MfgSopDocumentsController : ControllerBase
 
         var total = await baseQuery.CountAsync();
 
-        // 左联仅用于展示字段(Line/Op/imgType),主表字段仍可 CRUD
+        // 左联仅用于展示字段(Line/Op/imgType),主表字段仍可 CRUD。
+        // 关联口径一律 tenant_id + 业务键:Domain 是 legacy 兼容字段,同租户内不同表的 Domain
+        // 取值可能不一致(如 SOP 用组织 Id、LineMaster 用 8010),用它做关联会导致展示列解析不出来;
+        // 用 tenant_id 兜住跨租户,业务键保证语义正确。
         var list = await baseQuery
-            .LeftJoin<AdoS0ImageType>((qsi, it) => qsi.ImageTypeID == it.ImageTypeID && qsi.Domain == it.Domain)
-            .LeftJoin<AdoS0LineMaster>((qsi, it, lm) => qsi.Line == lm.Line && qsi.Domain == lm.Domain)
-            .LeftJoin<AdoS0MfgRoutingOpDetail>((qsi, it, lm, rd) => qsi.ItemNum == rd.MaterialCode && qsi.Op == rd.OperationCode)
+            .LeftJoin<AdoS0ImageType>((qsi, it) => qsi.TenantId == it.TenantId && qsi.ImageTypeID == it.ImageTypeID)
+            .LeftJoin<AdoS0LineMaster>((qsi, it, lm) => qsi.TenantId == lm.TenantId && qsi.Line == lm.Line)
+            .LeftJoin<AdoS0MfgRoutingOpDetail>((qsi, it, lm, rd) => qsi.TenantId == rd.TenantId && qsi.ItemNum == rd.MaterialCode && qsi.Op == rd.OperationCode)
             .OrderByDescending((qsi, it, lm, rd) => qsi.Id)
             .Select((qsi, it, lm, rd) => new
             {

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Manufacturing/AdoS0ManufacturingDtos.cs

@@ -105,9 +105,10 @@ public class AdoS0MfgMaterialProcessElementQueryDto : AdoS0MfgPagedQueryBase
 /// </summary>
 public class AdoS0MfgSopDocumentQueryDto
 {
-    [Required(ErrorMessage = "工厂域不能为空")]
+    // 去 domain 化(S0-SOP-DOMAIN-DECOUPLING-FIX):查询不再要求 Domain,放宽 [Required] 为可空;
+    // 租户隔离由 tenant_id 保证,Domain 仅保留为兼容字段,不参与列表可见性判定。
     [MaxLength(50)]
-    public string Domain { get; set; } = string.Empty;
+    public string? Domain { get; set; }
 
     public string? Keyword { get; set; }