| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164 |
- using SqlSugar;
- namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
- /// <summary>
- /// 租户合法库位白名单的**唯一加载口径**。LIVE 与 STD 必须共用本方法,
- /// 否则两条链路的安全边界会各自漂移(曾出现 LIVE 排除 Supp、STD 未排除)。
- /// </summary>
- public static class TenantLocationScopeLoader
- {
- /// <summary>
- /// 合法库位的**唯一谓词**。查询层白名单与页面库位下拉必须共用它,
- /// 否则会出现「下拉能选到、查询永远 0 条」的口径不一致。
- /// </summary>
- private const string ScopePredicate =
- """
- tenant_id=@TenantId AND Domain=@Domain
- AND IFNULL(Typed,'')<>'Supp'
- AND TRIM(Location)<>''
- """;
- private static List<SugarParameter> ScopeParameters(long tenantId, string domain) =>
- new() { new SugarParameter("@TenantId", tenantId), new SugarParameter("@Domain", domain) };
- /// <summary>
- /// 读取指定租户在指定 Domain 下的合法库位(<c>Typed <> 'Supp'</c>、非空库位)。
- /// 供应商/寄存库存(Supp)不属于本租户自有库存,一律排除。
- /// </summary>
- public static async Task<List<string>> LoadAsync(
- ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
- {
- return await db.Ado.SqlQueryAsync<string>(
- $"""
- SELECT DISTINCT Location
- FROM LocationMaster
- WHERE {ScopePredicate}
- """,
- ScopeParameters(tenantId, domain));
- }
- /// <summary>
- /// 页面库位下拉选项:与 <see cref="LoadAsync"/> 使用**同一谓词**,
- /// 保证「下拉里能选到的」恒等于「查询层允许查的」。同库位多行时取一个描述。
- /// </summary>
- public static async Task<List<TenantLocationOption>> LoadOptionsAsync(
- ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
- {
- return await db.Ado.SqlQueryAsync<TenantLocationOption>(
- $"""
- SELECT Location AS Val, MAX(Descr) AS Label
- FROM LocationMaster
- WHERE {ScopePredicate}
- GROUP BY Location
- ORDER BY Location
- """,
- ScopeParameters(tenantId, domain));
- }
- /// <summary>读取并直接构造安全边界。</summary>
- public static async Task<TenantLocationScope> LoadScopeAsync(
- ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
- => TenantLocationScope.FromWhitelist(await LoadAsync(db, tenantId, domain, cancellationToken));
- }
- /// <summary>库位下拉选项(编码 + 名称)。</summary>
- public sealed class TenantLocationOption
- {
- public string? Val { get; set; }
- public string? Label { get; set; }
- }
- /// <summary>
- /// 租户库位安全边界(源库直读专用)。
- /// <para>
- /// 安全不变量:任意直读结果行的 Location 必须 ∈ 本 Scope;
- /// 本 Scope 由「当前租户在 LocationMaster 中的合法库位(Typed <> 'Supp')」构成。
- /// </para>
- /// <para>
- /// 用户传入的 Location 筛选只能通过 <see cref="Intersect"/> 收窄本 Scope,
- /// 不得替代、不得绕过;空 Scope 一律 fail closed(EMPTY SCOPE != FULL DOMAIN)。
- /// </para>
- /// </summary>
- public sealed class TenantLocationScope
- {
- /// <summary>单条语句下发的库位参数上限(SQL Server 硬上限 2100,此处留足余量)。</summary>
- public const int MaxParameters = 1000;
- private readonly List<string> _locations;
- private TenantLocationScope(List<string> locations) => _locations = locations;
- /// <summary>空边界:不得据此查询,必须 fail closed。</summary>
- public static TenantLocationScope Empty { get; } = new(new List<string>());
- public IReadOnlyList<string> Locations => _locations;
- public int Count => _locations.Count;
- public bool IsEmpty => _locations.Count == 0;
- /// <summary>
- /// 由白名单原始行构造:去首尾空白、丢弃空值、按忽略大小写去重,并保留库中的原始写法。
- /// </summary>
- public static TenantLocationScope FromWhitelist(IEnumerable<string?>? whitelist)
- {
- if (whitelist is null) return Empty;
- var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
- var list = new List<string>();
- foreach (var raw in whitelist)
- {
- if (string.IsNullOrWhiteSpace(raw)) continue;
- var value = raw.Trim();
- if (seen.Add(value)) list.Add(value);
- }
- return list.Count == 0 ? Empty : new TenantLocationScope(list);
- }
- /// <summary>本 Scope 是否覆盖某库位(忽略大小写与首尾空白)。</summary>
- public bool Contains(string? location)
- {
- if (string.IsNullOrWhiteSpace(location)) return false;
- var value = location.Trim();
- return _locations.Any(x => string.Equals(x, value, StringComparison.OrdinalIgnoreCase));
- }
- /// <summary>
- /// 与用户显式指定的库位求交。
- /// 未指定 → 维持整个租户边界;指定且命中 → 收窄为该库位;指定但越界 → <see cref="Empty"/>。
- /// </summary>
- public TenantLocationScope Intersect(string? requestedLocation)
- {
- if (string.IsNullOrWhiteSpace(requestedLocation)) return this;
- var want = requestedLocation.Trim();
- var hit = _locations.FirstOrDefault(x => string.Equals(x, want, StringComparison.OrdinalIgnoreCase));
- return hit is null ? Empty : new TenantLocationScope(new List<string> { hit });
- }
- /// <summary>
- /// 生成参数化 <c>IN</c> 子句。库位值一律走 <see cref="SugarParameter"/>,禁止拼进 SQL 文本。
- /// </summary>
- /// <exception cref="InvalidOperationException">Scope 为空(调用方应先 fail closed),或超出参数数量上限。</exception>
- public (string Clause, List<SugarParameter> Parameters) BuildInClause(string column, string parameterPrefix)
- {
- if (string.IsNullOrWhiteSpace(column)) throw new ArgumentException("column 不能为空", nameof(column));
- if (string.IsNullOrWhiteSpace(parameterPrefix)) throw new ArgumentException("parameterPrefix 不能为空", nameof(parameterPrefix));
- if (IsEmpty)
- throw new InvalidOperationException("空租户库位边界不得生成 IN 子句:调用方必须先 fail closed");
- if (_locations.Count > MaxParameters)
- throw new InvalidOperationException(
- $"租户库位白名单过大({_locations.Count} > {MaxParameters}),拒绝下发以免超出数据库参数上限");
- var names = new List<string>(_locations.Count);
- var parameters = new List<SugarParameter>(_locations.Count);
- for (var i = 0; i < _locations.Count; i++)
- {
- var name = $"@{parameterPrefix}{i}";
- names.Add(name);
- parameters.Add(new SugarParameter(name, _locations[i]));
- }
- return ($"{column} IN ({string.Join(",", names)})", parameters);
- }
- }
|