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