SourceDomainTenantResolver.cs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. using SqlSugar;
  2. namespace Admin.NET.Plugin.AiDOP.Infrastructure;
  3. /// <summary>
  4. /// Domain ↔ Ai-DOP tenant 解析器。
  5. /// 直接查询 sys_tenant 系统租户表验证租户有效性,无需手动维护映射表。
  6. /// Domain 一律按**当前租户自身主数据**解析:优先 GeneralizedCodeMaster.SystemConfig.CompanyCode,
  7. /// 缺失时回落到该租户自己的 LocationMaster 唯一 Domain;两者都不可得或不唯一 → fail closed。
  8. /// **禁止任何硬编码 Domain 兜底**(历史上的 "8010" 兜底会把租户串到别人的 Domain)。
  9. /// </summary>
  10. public sealed class SourceDomainTenantResolver : ITransient
  11. {
  12. private readonly ISqlSugarClient _db;
  13. public SourceDomainTenantResolver(ISqlSugarClient db) => _db = db;
  14. /// <summary>
  15. /// 根据 sourceCode + domain 解析**源归属租户**(source owner)。
  16. /// <para>
  17. /// 语义:一个 (source, domain) 组合物理上属于哪一个 DOP 租户的贴源落地区(stg)。
  18. /// 唯一依据是显式登记表 <c>ado_source_domain_tenant_map</c>;未登记即 fail closed。
  19. /// </para>
  20. /// <para>
  21. /// ⚠️ 本方法**不回答**「某条库存数据业务上属于哪个租户」——同一个 domain 可被多个租户
  22. /// 按各自库位范围共享(如 8010 同时被 AIDOP 与 UATTEST_CHL 使用),
  23. /// 业务归属由标准层物化时按各租户 LocationMaster 库位范围投影决定。
  24. /// </para>
  25. /// <para>
  26. /// 历史缺陷:本方法曾忽略 domain、恒取 <c>SysTenant WHERE Status=1 ORDER BY Id LIMIT 1</c>
  27. /// (即默认租户),导致整份 domain 快照被塞进与业务无关的租户。已删除该行为。
  28. /// </para>
  29. /// </summary>
  30. public async Task<long> ResolveTenantIdAsync(
  31. string sourceCode,
  32. string domain,
  33. CancellationToken cancellationToken = default)
  34. {
  35. if (string.IsNullOrWhiteSpace(sourceCode) || string.IsNullOrWhiteSpace(domain))
  36. throw new InvalidOperationException("sourceCode/domain 不能为空");
  37. var code = sourceCode.Trim();
  38. var dom = domain.Trim();
  39. var rows = await _db.Ado.SqlQueryAsync<MapRow>(
  40. """
  41. SELECT DISTINCT m.tenant_id AS TenantId
  42. FROM ado_source_domain_tenant_map m
  43. JOIN SysTenant t ON t.Id = m.tenant_id AND t.Status = 1
  44. WHERE m.source_code = @SourceCode
  45. AND m.domain = @Domain
  46. AND m.status = 1
  47. """,
  48. new List<SugarParameter>
  49. {
  50. new("@SourceCode", code),
  51. new("@Domain", dom)
  52. });
  53. // fail closed:既不猜第一个租户,也不回落默认租户
  54. if (rows.Count == 0)
  55. throw new InvalidOperationException(
  56. $"源归属租户未登记:source={code}, domain={dom}(请在 ado_source_domain_tenant_map 显式登记)");
  57. if (rows.Count > 1)
  58. throw new InvalidOperationException(
  59. $"源归属租户不唯一:source={code}, domain={dom}, tenants=[{string.Join(",", rows.Select(x => x.TenantId))}]");
  60. if (rows[0].TenantId <= 0)
  61. throw new InvalidOperationException($"源归属租户 Id 非法:source={code}, domain={dom}");
  62. return rows[0].TenantId;
  63. }
  64. /// <summary>
  65. /// 根据 sourceCode + tenantId 解析 domain。
  66. /// 先在 sys_tenant 验证租户存在且启用,再**按该租户自身主数据**解析 Domain:
  67. /// ① GeneralizedCodeMaster.SystemConfig.CompanyCode(tenant 限定);
  68. /// ② 缺失时回落该租户 LocationMaster 的唯一 Domain。
  69. /// 两者都取不到、或取到多个无法判定 → 抛异常 fail closed,绝不兜底到任何固定 Domain。
  70. /// </summary>
  71. public async Task<string> ResolveDomainAsync(
  72. string sourceCode,
  73. long tenantId,
  74. CancellationToken cancellationToken = default)
  75. {
  76. if (string.IsNullOrWhiteSpace(sourceCode) || tenantId <= 0)
  77. throw new InvalidOperationException("sourceCode/tenantId 非法");
  78. // 验证租户在 sys_tenant 中存在且启用
  79. var tenantExists = await _db.Ado.GetIntAsync(
  80. "SELECT COUNT(1) FROM SysTenant WHERE Id = @TenantId AND Status = 1",
  81. new List<SugarParameter> { new("@TenantId", tenantId) });
  82. if (tenantExists <= 0)
  83. throw new InvalidOperationException($"租户不存在或未启用:tenant={tenantId}");
  84. // ① 主口径:当前租户自己配置的公司代码
  85. var configured = await _db.Ado.SqlQueryAsync<DomainRow>(
  86. """
  87. SELECT DISTINCT Domain
  88. FROM GeneralizedCodeMaster
  89. WHERE tenant_id = @TenantId
  90. AND FldName = 'SystemConfig' AND Val = 'CompanyCode'
  91. AND IFNULL(Domain,'') <> ''
  92. """,
  93. new List<SugarParameter> { new("@TenantId", tenantId) });
  94. var domain = PickSingleDomain(configured, tenantId, "GeneralizedCodeMaster.SystemConfig.CompanyCode");
  95. if (domain is not null) return domain;
  96. // ② 次口径:当前租户自己的库位主数据(同样 tenant 限定,且必须唯一)
  97. var derived = await _db.Ado.SqlQueryAsync<DomainRow>(
  98. """
  99. SELECT DISTINCT Domain
  100. FROM LocationMaster
  101. WHERE tenant_id = @TenantId AND IFNULL(Domain,'') <> ''
  102. """,
  103. new List<SugarParameter> { new("@TenantId", tenantId) });
  104. domain = PickSingleDomain(derived, tenantId, "LocationMaster");
  105. if (domain is not null) return domain;
  106. // ③ fail closed:宁可查询失败,也不把租户串到别人的 Domain
  107. throw new InvalidOperationException(
  108. $"租户未配置 Domain 映射:tenant={tenantId}(GeneralizedCodeMaster.SystemConfig.CompanyCode 缺失," +
  109. "且该租户 LocationMaster 无法唯一推导 Domain)");
  110. }
  111. /// <summary>取唯一 Domain:0 条返回 null 交由下一口径;多条直接 fail closed,不猜。</summary>
  112. private static string? PickSingleDomain(List<DomainRow> rows, long tenantId, string source)
  113. {
  114. var domains = rows
  115. .Select(x => x.Domain?.Trim())
  116. .Where(x => !string.IsNullOrEmpty(x))
  117. .Distinct(StringComparer.OrdinalIgnoreCase)
  118. .ToList();
  119. if (domains.Count == 0) return null;
  120. if (domains.Count > 1)
  121. throw new InvalidOperationException(
  122. $"租户 Domain 映射不唯一:tenant={tenantId}, source={source}, domains=[{string.Join(",", domains)}]");
  123. return domains[0];
  124. }
  125. private sealed class MapRow
  126. {
  127. public long TenantId { get; set; }
  128. }
  129. private sealed class DomainRow
  130. {
  131. public string? Domain { get; set; }
  132. }
  133. }