AdoS0OrgScope.cs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. using Microsoft.AspNetCore.Mvc;
  2. namespace Admin.NET.Plugin.AiDOP.Infrastructure;
  3. /// <summary>
  4. /// S0 组织归属(company_ref_id / factory_ref_id)的服务端唯一裁决者。
  5. ///
  6. /// 背景(S0-ORG-REFERENCE-MODEL-FIX-B1):
  7. /// 2026-06-14 commit a18c8a6c5 在「单部署」假设下给 S0 仓储页写死了
  8. /// DEFAULT_COMPANY_REF_ID=1329900200001 / DEFAULT_FACTORY_REF_ID=1329900200002;
  9. /// 其后 1.0.317.sql 把这两个 SysOrg 改挂到 Demo 租户 797403760988230,
  10. /// 前端常量却从未同步 → 任何租户新增都写入别租户的 SysOrg.Id,形成跨租户组织引用。
  11. ///
  12. /// 铁律:
  13. /// - 组织归属**只由服务端**在当前租户内解析,一律忽略 payload 的组织值;
  14. /// 前端 payload 不可信(老前端 / 手工 API / 其它客户端都可能带错值)。
  15. /// - tenantId 必须来自 <see cref="AdoS0TenantScope.TryResolveRequired"/>(JWT claim),
  16. /// 绝不接受前端传入的 tenantId。
  17. /// - **绝不跨租户查找**:候选集一律先按 TenantId 收窄。
  18. /// - **绝不新增组织**:解析不出就返回 (0,0),由调用方转 400 业务错误,不自动建公司/工厂。
  19. ///
  20. /// 本实现自 <c>AdoS0ProcessFlowCardsController.ResolveDefaultOrgAsync</c> 原样抽取
  21. /// (该实现已实测产出 8/8 SAME-TENANT),语义保持不变,避免多份重复 resolver 漂移。
  22. /// </summary>
  23. public static class AdoS0OrgScope
  24. {
  25. /// <summary>租户内优先匹配的公司编码(仅作优先条件,取不到即按机构类型/根节点在本租户内兜底)。</summary>
  26. private const string PreferredCompanyCode = "S0-C001";
  27. /// <summary>租户内优先匹配的工厂编码(同上,绝不跨租户查找)。</summary>
  28. private const string PreferredFactoryCode = "S0-F001";
  29. /// <summary>机构类型-公司(SysOrg.Type 数据字典值)。</summary>
  30. private const string CompanyOrgType = "201";
  31. /// <summary>
  32. /// 机构类型-工厂。
  33. /// ⚠️ S0 legacy compatibility: Type=501 used as factory.
  34. /// 平台数据字典 org_type 中 501 的官方语义是「组」(101=集团 / 201=公司 / 301=部门 / 501=组),
  35. /// S0 自 S0OrgSeedData 起单方面把 501 复用为「工厂」。当前全库 Type=501 的组织均为 AiDOP
  36. /// 自建工厂节点,无真实「组」,故保持兼容不重构;后续若出现真实「组」需另开批次消歧。
  37. /// </summary>
  38. private const string FactoryOrgType = "501";
  39. /// <summary>
  40. /// 在**当前租户内**解析 S0 默认公司/工厂组织。
  41. ///
  42. /// 优先级——公司:
  43. /// C1 本租户 Code=S0-C001 → C3 本租户根节点(Pid=0)且 Type=201
  44. /// → C2 本租户任意 Type=201 → C4 本租户任意根节点(Pid=0)
  45. /// 优先级——工厂:
  46. /// F1 本租户 Code=S0-F001 且父级为已解析公司 → F2 本租户 Type=501 且父级为已解析公司
  47. /// → F3 本租户任意 Type=501 → F4 回落到公司自身(兼容未建工厂节点的租户,不新增组织)。
  48. ///
  49. /// 确定性:一次取本租户机构并按 (OrderNo, Id) 稳定排序,各级用 FirstOrDefault 在有序集合上取首个,
  50. /// 避免多候选时结果随执行计划漂移。
  51. ///
  52. /// 无解:返回 (0, 0),由调用方转 400 业务错误,**不抛异常**——
  53. /// AdoS0ExceptionFilter 会把 /api/s0/* 上的未处理异常统一映射为 500 S01999,
  54. /// 抛异常会把「租户未配置机构」这类业务态误报成系统故障。
  55. /// </summary>
  56. /// <param name="db">SqlSugar 上下文(调用方一般传 <c>_rep.Context</c>,无需额外注入 SysOrg 仓储)。</param>
  57. /// <param name="tenantId">AdoS0TenantScope 解析出的可信租户(JWT claim),不接受 payload 传入。</param>
  58. public static async Task<(long CompanyId, long FactoryId)> ResolveAsync(ISqlSugarClient db, long tenantId)
  59. {
  60. if (tenantId <= 0) return (0, 0);
  61. // 单次取数 + 内存分级,避免逐级往返;排序在 SQL 侧一次确定,后续各级取首个即稳定。
  62. var orgs = await db.Queryable<SysOrg>()
  63. .Where(x => x.TenantId == tenantId)
  64. .OrderBy(x => new { x.OrderNo, x.Id })
  65. .Select(x => new { x.Id, x.Code, x.Type, x.Pid })
  66. .ToListAsync();
  67. if (orgs.Count == 0) return (0, 0);
  68. var company =
  69. orgs.FirstOrDefault(o => o.Code == PreferredCompanyCode) // C1
  70. ?? orgs.FirstOrDefault(o => o.Pid == 0 && o.Type == CompanyOrgType) // C3
  71. ?? orgs.FirstOrDefault(o => o.Type == CompanyOrgType) // C2
  72. ?? orgs.FirstOrDefault(o => o.Pid == 0); // C4
  73. if (company == null) return (0, 0);
  74. var factory =
  75. orgs.FirstOrDefault(o => o.Code == PreferredFactoryCode && o.Pid == company.Id) // F1
  76. ?? orgs.FirstOrDefault(o => o.Type == FactoryOrgType && o.Pid == company.Id) // F2
  77. ?? orgs.FirstOrDefault(o => o.Type == FactoryOrgType); // F3
  78. return (company.Id, factory?.Id ?? company.Id); // F4
  79. }
  80. /// <summary>
  81. /// 解析失败时的统一业务错误(各 Controller 复用同一文案,便于前端与运维识别)。
  82. /// </summary>
  83. public static IActionResult NotConfiguredError(string entityLabel)
  84. => AdoS0ApiErrors.InvalidRequest($"当前租户未配置机构,无法确定{entityLabel}的公司/工厂归属,请先在机构管理中维护");
  85. /// <summary>
  86. /// 校验 payload 带来的组织引用未越出当前租户(B-1b:用于「页面自己驱动工厂维度」的接口,
  87. /// 这类接口不能强制 stamp 成解析值,否则会与页面的工厂选择错位,故改为放行 + 越权拒绝)。
  88. ///
  89. /// 判定口径——**只拒绝「确实解析到 SysOrg 且属于他租户」的值**:
  90. /// - 0 / 负数:视为未设置,放行(由各 Controller 自身的必填校验处理);
  91. /// - 在 SysOrg 中查不到(如 legacy 数值域 1000 / 1 等历史值):**不属于跨租户引用**,放行;
  92. /// legacy 数值域的治理是独立批次,本校验不得把历史数据编辑一并拒绝掉。
  93. /// - 能查到且 TenantId != 当前租户:**拒绝**——这正是要堵的越权写入。
  94. ///
  95. /// 返回 null 表示校验通过;否则返回可直接 return 的 400 业务错误。
  96. /// </summary>
  97. /// <param name="db">SqlSugar 上下文(调用方一般传 <c>_rep.Context</c>)。</param>
  98. /// <param name="tenantId">AdoS0TenantScope 解析出的可信租户(JWT claim)。</param>
  99. /// <param name="companyRefId">payload 的公司引用。</param>
  100. /// <param name="factoryRefId">payload 的工厂引用。</param>
  101. public static async Task<IActionResult?> ValidateOrgBelongsToTenantAsync(
  102. ISqlSugarClient db, long tenantId, long companyRefId, long factoryRefId)
  103. {
  104. var candidates = new List<long>();
  105. if (companyRefId > 0) candidates.Add(companyRefId);
  106. if (factoryRefId > 0 && factoryRefId != companyRefId) candidates.Add(factoryRefId);
  107. if (candidates.Count == 0) return null;
  108. // 一次取回候选组织的归属;查不到的 id 不在结果里 → 按「非 SysOrg 引用」放行。
  109. //
  110. // ⚠️ 必须 ClearFilter():SysOrg 继承 EntityBaseTenant,带 SqlSugar 全局租户过滤器。
  111. // 不清过滤器时,他租户的组织**根本查不出来**,owners 恒为空 → 越权值会被误判为
  112. // 「非 SysOrg 引用」而放行,本校验形同虚设(B-1b 首轮负控实测即因此漏过)。
  113. // 本查询只读取 SysOrg 的 Id/TenantId 用于归属判定,不返回任何他租户业务数据。
  114. var owners = await db.Queryable<SysOrg>()
  115. .ClearFilter()
  116. .Where(x => candidates.Contains(x.Id))
  117. .Select(x => new { x.Id, x.TenantId })
  118. .ToListAsync();
  119. var offending = owners.FirstOrDefault(o => o.TenantId != tenantId);
  120. if (offending != null)
  121. {
  122. return AdoS0ApiErrors.InvalidRequest(
  123. $"公司/工厂归属非法:组织 {offending.Id} 不属于当前租户,拒绝写入");
  124. }
  125. return null;
  126. }
  127. }