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);
}
}