TenantLocationScope.cs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. using SqlSugar;
  2. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  3. /// <summary>
  4. /// 租户合法库位白名单的**唯一加载口径**。LIVE 与 STD 必须共用本方法,
  5. /// 否则两条链路的安全边界会各自漂移(曾出现 LIVE 排除 Supp、STD 未排除)。
  6. /// </summary>
  7. public static class TenantLocationScopeLoader
  8. {
  9. /// <summary>
  10. /// 读取指定租户在指定 Domain 下的合法库位(<c>Typed &lt;&gt; 'Supp'</c>、非空库位)。
  11. /// 供应商/寄存库存(Supp)不属于本租户自有库存,一律排除。
  12. /// </summary>
  13. public static async Task<List<string>> LoadAsync(
  14. ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
  15. {
  16. return await db.Ado.SqlQueryAsync<string>(
  17. """
  18. SELECT DISTINCT Location
  19. FROM LocationMaster
  20. WHERE tenant_id=@TenantId AND Domain=@Domain
  21. AND IFNULL(Typed,'')<>'Supp'
  22. AND TRIM(Location)<>''
  23. """,
  24. new List<SugarParameter>
  25. {
  26. new("@TenantId", tenantId),
  27. new("@Domain", domain)
  28. });
  29. }
  30. /// <summary>读取并直接构造安全边界。</summary>
  31. public static async Task<TenantLocationScope> LoadScopeAsync(
  32. ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
  33. => TenantLocationScope.FromWhitelist(await LoadAsync(db, tenantId, domain, cancellationToken));
  34. }
  35. /// <summary>
  36. /// 租户库位安全边界(源库直读专用)。
  37. /// <para>
  38. /// 安全不变量:任意直读结果行的 Location 必须 ∈ 本 Scope;
  39. /// 本 Scope 由「当前租户在 LocationMaster 中的合法库位(Typed &lt;&gt; 'Supp')」构成。
  40. /// </para>
  41. /// <para>
  42. /// 用户传入的 Location 筛选只能通过 <see cref="Intersect"/> 收窄本 Scope,
  43. /// 不得替代、不得绕过;空 Scope 一律 fail closed(EMPTY SCOPE != FULL DOMAIN)。
  44. /// </para>
  45. /// </summary>
  46. public sealed class TenantLocationScope
  47. {
  48. /// <summary>单条语句下发的库位参数上限(SQL Server 硬上限 2100,此处留足余量)。</summary>
  49. public const int MaxParameters = 1000;
  50. private readonly List<string> _locations;
  51. private TenantLocationScope(List<string> locations) => _locations = locations;
  52. /// <summary>空边界:不得据此查询,必须 fail closed。</summary>
  53. public static TenantLocationScope Empty { get; } = new(new List<string>());
  54. public IReadOnlyList<string> Locations => _locations;
  55. public int Count => _locations.Count;
  56. public bool IsEmpty => _locations.Count == 0;
  57. /// <summary>
  58. /// 由白名单原始行构造:去首尾空白、丢弃空值、按忽略大小写去重,并保留库中的原始写法。
  59. /// </summary>
  60. public static TenantLocationScope FromWhitelist(IEnumerable<string?>? whitelist)
  61. {
  62. if (whitelist is null) return Empty;
  63. var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  64. var list = new List<string>();
  65. foreach (var raw in whitelist)
  66. {
  67. if (string.IsNullOrWhiteSpace(raw)) continue;
  68. var value = raw.Trim();
  69. if (seen.Add(value)) list.Add(value);
  70. }
  71. return list.Count == 0 ? Empty : new TenantLocationScope(list);
  72. }
  73. /// <summary>本 Scope 是否覆盖某库位(忽略大小写与首尾空白)。</summary>
  74. public bool Contains(string? location)
  75. {
  76. if (string.IsNullOrWhiteSpace(location)) return false;
  77. var value = location.Trim();
  78. return _locations.Any(x => string.Equals(x, value, StringComparison.OrdinalIgnoreCase));
  79. }
  80. /// <summary>
  81. /// 与用户显式指定的库位求交。
  82. /// 未指定 → 维持整个租户边界;指定且命中 → 收窄为该库位;指定但越界 → <see cref="Empty"/>。
  83. /// </summary>
  84. public TenantLocationScope Intersect(string? requestedLocation)
  85. {
  86. if (string.IsNullOrWhiteSpace(requestedLocation)) return this;
  87. var want = requestedLocation.Trim();
  88. var hit = _locations.FirstOrDefault(x => string.Equals(x, want, StringComparison.OrdinalIgnoreCase));
  89. return hit is null ? Empty : new TenantLocationScope(new List<string> { hit });
  90. }
  91. /// <summary>
  92. /// 生成参数化 <c>IN</c> 子句。库位值一律走 <see cref="SugarParameter"/>,禁止拼进 SQL 文本。
  93. /// </summary>
  94. /// <exception cref="InvalidOperationException">Scope 为空(调用方应先 fail closed),或超出参数数量上限。</exception>
  95. public (string Clause, List<SugarParameter> Parameters) BuildInClause(string column, string parameterPrefix)
  96. {
  97. if (string.IsNullOrWhiteSpace(column)) throw new ArgumentException("column 不能为空", nameof(column));
  98. if (string.IsNullOrWhiteSpace(parameterPrefix)) throw new ArgumentException("parameterPrefix 不能为空", nameof(parameterPrefix));
  99. if (IsEmpty)
  100. throw new InvalidOperationException("空租户库位边界不得生成 IN 子句:调用方必须先 fail closed");
  101. if (_locations.Count > MaxParameters)
  102. throw new InvalidOperationException(
  103. $"租户库位白名单过大({_locations.Count} > {MaxParameters}),拒绝下发以免超出数据库参数上限");
  104. var names = new List<string>(_locations.Count);
  105. var parameters = new List<SugarParameter>(_locations.Count);
  106. for (var i = 0; i < _locations.Count; i++)
  107. {
  108. var name = $"@{parameterPrefix}{i}";
  109. names.Add(name);
  110. parameters.Add(new SugarParameter(name, _locations[i]));
  111. }
  112. return ($"{column} IN ({string.Join(",", names)})", parameters);
  113. }
  114. }