TenantLocationScope.cs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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. /// 合法库位的**唯一谓词**。查询层白名单与页面库位下拉必须共用它,
  11. /// 否则会出现「下拉能选到、查询永远 0 条」的口径不一致。
  12. /// </summary>
  13. private const string ScopePredicate =
  14. """
  15. tenant_id=@TenantId AND Domain=@Domain
  16. AND IFNULL(Typed,'')<>'Supp'
  17. AND TRIM(Location)<>''
  18. """;
  19. private static List<SugarParameter> ScopeParameters(long tenantId, string domain) =>
  20. new() { new SugarParameter("@TenantId", tenantId), new SugarParameter("@Domain", domain) };
  21. /// <summary>
  22. /// 读取指定租户在指定 Domain 下的合法库位(<c>Typed &lt;&gt; 'Supp'</c>、非空库位)。
  23. /// 供应商/寄存库存(Supp)不属于本租户自有库存,一律排除。
  24. /// </summary>
  25. public static async Task<List<string>> LoadAsync(
  26. ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
  27. {
  28. return await db.Ado.SqlQueryAsync<string>(
  29. $"""
  30. SELECT DISTINCT Location
  31. FROM LocationMaster
  32. WHERE {ScopePredicate}
  33. """,
  34. ScopeParameters(tenantId, domain));
  35. }
  36. /// <summary>
  37. /// 页面库位下拉选项:与 <see cref="LoadAsync"/> 使用**同一谓词**,
  38. /// 保证「下拉里能选到的」恒等于「查询层允许查的」。同库位多行时取一个描述。
  39. /// </summary>
  40. public static async Task<List<TenantLocationOption>> LoadOptionsAsync(
  41. ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
  42. {
  43. return await db.Ado.SqlQueryAsync<TenantLocationOption>(
  44. $"""
  45. SELECT Location AS Val, MAX(Descr) AS Label
  46. FROM LocationMaster
  47. WHERE {ScopePredicate}
  48. GROUP BY Location
  49. ORDER BY Location
  50. """,
  51. ScopeParameters(tenantId, domain));
  52. }
  53. /// <summary>读取并直接构造安全边界。</summary>
  54. public static async Task<TenantLocationScope> LoadScopeAsync(
  55. ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
  56. => TenantLocationScope.FromWhitelist(await LoadAsync(db, tenantId, domain, cancellationToken));
  57. }
  58. /// <summary>库位下拉选项(编码 + 名称)。</summary>
  59. public sealed class TenantLocationOption
  60. {
  61. public string? Val { get; set; }
  62. public string? Label { get; set; }
  63. }
  64. /// <summary>
  65. /// 租户库位安全边界(源库直读专用)。
  66. /// <para>
  67. /// 安全不变量:任意直读结果行的 Location 必须 ∈ 本 Scope;
  68. /// 本 Scope 由「当前租户在 LocationMaster 中的合法库位(Typed &lt;&gt; 'Supp')」构成。
  69. /// </para>
  70. /// <para>
  71. /// 用户传入的 Location 筛选只能通过 <see cref="Intersect"/> 收窄本 Scope,
  72. /// 不得替代、不得绕过;空 Scope 一律 fail closed(EMPTY SCOPE != FULL DOMAIN)。
  73. /// </para>
  74. /// </summary>
  75. public sealed class TenantLocationScope
  76. {
  77. /// <summary>单条语句下发的库位参数上限(SQL Server 硬上限 2100,此处留足余量)。</summary>
  78. public const int MaxParameters = 1000;
  79. private readonly List<string> _locations;
  80. private TenantLocationScope(List<string> locations) => _locations = locations;
  81. /// <summary>空边界:不得据此查询,必须 fail closed。</summary>
  82. public static TenantLocationScope Empty { get; } = new(new List<string>());
  83. public IReadOnlyList<string> Locations => _locations;
  84. public int Count => _locations.Count;
  85. public bool IsEmpty => _locations.Count == 0;
  86. /// <summary>
  87. /// 由白名单原始行构造:去首尾空白、丢弃空值、按忽略大小写去重,并保留库中的原始写法。
  88. /// </summary>
  89. public static TenantLocationScope FromWhitelist(IEnumerable<string?>? whitelist)
  90. {
  91. if (whitelist is null) return Empty;
  92. var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  93. var list = new List<string>();
  94. foreach (var raw in whitelist)
  95. {
  96. if (string.IsNullOrWhiteSpace(raw)) continue;
  97. var value = raw.Trim();
  98. if (seen.Add(value)) list.Add(value);
  99. }
  100. return list.Count == 0 ? Empty : new TenantLocationScope(list);
  101. }
  102. /// <summary>本 Scope 是否覆盖某库位(忽略大小写与首尾空白)。</summary>
  103. public bool Contains(string? location)
  104. {
  105. if (string.IsNullOrWhiteSpace(location)) return false;
  106. var value = location.Trim();
  107. return _locations.Any(x => string.Equals(x, value, StringComparison.OrdinalIgnoreCase));
  108. }
  109. /// <summary>
  110. /// 与用户显式指定的库位求交。
  111. /// 未指定 → 维持整个租户边界;指定且命中 → 收窄为该库位;指定但越界 → <see cref="Empty"/>。
  112. /// </summary>
  113. public TenantLocationScope Intersect(string? requestedLocation)
  114. {
  115. if (string.IsNullOrWhiteSpace(requestedLocation)) return this;
  116. var want = requestedLocation.Trim();
  117. var hit = _locations.FirstOrDefault(x => string.Equals(x, want, StringComparison.OrdinalIgnoreCase));
  118. return hit is null ? Empty : new TenantLocationScope(new List<string> { hit });
  119. }
  120. /// <summary>
  121. /// 生成参数化 <c>IN</c> 子句。库位值一律走 <see cref="SugarParameter"/>,禁止拼进 SQL 文本。
  122. /// </summary>
  123. /// <exception cref="InvalidOperationException">Scope 为空(调用方应先 fail closed),或超出参数数量上限。</exception>
  124. public (string Clause, List<SugarParameter> Parameters) BuildInClause(string column, string parameterPrefix)
  125. {
  126. if (string.IsNullOrWhiteSpace(column)) throw new ArgumentException("column 不能为空", nameof(column));
  127. if (string.IsNullOrWhiteSpace(parameterPrefix)) throw new ArgumentException("parameterPrefix 不能为空", nameof(parameterPrefix));
  128. if (IsEmpty)
  129. throw new InvalidOperationException("空租户库位边界不得生成 IN 子句:调用方必须先 fail closed");
  130. if (_locations.Count > MaxParameters)
  131. throw new InvalidOperationException(
  132. $"租户库位白名单过大({_locations.Count} > {MaxParameters}),拒绝下发以免超出数据库参数上限");
  133. var names = new List<string>(_locations.Count);
  134. var parameters = new List<SugarParameter>(_locations.Count);
  135. for (var i = 0; i < _locations.Count; i++)
  136. {
  137. var name = $"@{parameterPrefix}{i}";
  138. names.Add(name);
  139. parameters.Add(new SugarParameter(name, _locations[i]));
  140. }
  141. return ($"{column} IN ({string.Join(",", names)})", parameters);
  142. }
  143. }