Просмотр исходного кода

fix(mdp): 修复作用域探针丢失租户维度导致的零行误判

零行守卫在本页取空时会跑一个"去掉作用域再看一眼"的探针,用来区分
「源侧本就没数据」与「数据被作用域谓词滤光了」。但它把租户与工厂
两个谓词一起剥掉了,于是探针退化成 SELECT 1 FROM 源表 LIMIT 1。

在多租户共享同一张源表的模型下,这个查询对任何租户都会命中别的租户的行。
后果是方向完全相反的两类误判:合法空租户被判成 SCOPE_MISMATCH,
而真正被工厂谓词滤空的租户,因为其它原因没走到探针,反而一条都没报出来。
共享库上累计 291 条 SCOPE_MISMATCH,全部落在源表本就 0 行的租户上。

探针要回答的是「同一租户内,是否因工厂口径而取空」,不是「整张表有没有数据」。
丢掉租户维度,这个问题就问不出来了。因此改为保留租户谓词、只去掉工厂谓词;
参数剥离随之分流,与探针是否发射租户谓词严格一致,避免参数缺失或多绑。

共享库只读复算(未执行任何写操作):
  合法空租户 1300000000777    旧探针命中 660 → 误报 PARTIAL;新探针命中 0 → SUCCESS
  被工厂谓词滤空的 797403760988229  旧探针 660(碰巧对);新探针 508 → PARTIAL(真阳性保留)

注意这不修零行本身——共享库上 Rule03 贴源长期零行的根因是部署缺口
(scope 修复已在 1.0.517 随 MdpFactoryScope 上线,但仍有 pre-fix 构建在驱动 S1 扫描),
本次只让诊断信号说真话。
YY968XX 1 день назад
Родитель
Сommit
5ba3f1b6c5

+ 46 - 7
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MdpDbPullScopeContractTests.cs

@@ -252,8 +252,19 @@ public class MdpDbPullZeroRowGuardTests
         Assert.Equal("FAILED", MdpDbPullExecutor.ResolveSyncStatus(null, "boom"));
     }
 
+    /// <summary>
+    /// 探针必须<b>保留租户谓词、只去掉工厂谓词</b>。
+    ///
+    /// <para>此前两个作用域谓词一起剥掉,探针退化成 <c>SELECT 1 FROM 源表 LIMIT 1</c>。
+    /// 在多租户共享同一张源表的模型下,它对任何租户都会命中别的租户的行 ——
+    /// 于是每个<b>合法空租户</b>都被误报成 SCOPE_MISMATCH(共享库上累计 291 条误报),
+    /// 而真正被工厂谓词滤空的租户反而一条都没报出来,信号方向是反的。</para>
+    ///
+    /// <para>探针要回答的是「同一租户内,是否因工厂口径而取空」,
+    /// 不是「整张表有没有数据」。</para>
+    /// </summary>
     [Fact]
-    public void ProbeSql_DropsScopePredicatesButKeepsIncrementalWindow()
+    public void ProbeSql_KeepsTenantPredicate_DropsOnlyFactoryPredicate()
     {
         var entity = new Admin.NET.Plugin.AiDOP.Entity.DataPlatform.MdpEntity
         {
@@ -263,13 +274,31 @@ public class MdpDbPullZeroRowGuardTests
         };
         var ctx = new MdpPullContext { TenantId = 797403760988229, FactoryId = 1, FullRefresh = false };
 
-        var probe = MdpDbPullExecutor.BuildScopeProbeSql(entity, isSqlServer: false, ctx, useKeyset: false);
+        var probe = MdpDbPullExecutor.BuildScopeProbeSql(
+            entity, isSqlServer: false, ctx, useKeyset: false, tenantCol: "tenant_id");
+
+        Assert.Contains("`tenant_id` = @scopeTenantId", probe);   // 租户维度必须保留
+        Assert.DoesNotContain("@scopeFactoryId", probe);          // 工厂维度正是要去掉的那个
+        Assert.Contains("`update_time` > @cursor", probe);        // 增量窗口保留,避免把"增量无新数据"误判为作用域异常
+        Assert.Contains("FROM b_examine_result", probe);
+        Assert.Contains("LIMIT 1", probe);                        // 只做存在性判断
+    }
+
+    /// <summary>源表没有租户列时,探针自然不发射租户谓词——不得凭空造一个。</summary>
+    [Fact]
+    public void ProbeSql_OmitsTenantPredicate_WhenSourceHasNoTenantColumn()
+    {
+        var entity = new Admin.NET.Plugin.AiDOP.Entity.DataPlatform.MdpEntity
+        {
+            SourceTableName = "b_examine_result"
+        };
+        var ctx = new MdpPullContext { TenantId = 797403760988229, FactoryId = 1 };
+
+        var probe = MdpDbPullExecutor.BuildScopeProbeSql(
+            entity, isSqlServer: false, ctx, useKeyset: false, tenantCol: null);
 
         Assert.DoesNotContain("@scopeTenantId", probe);
         Assert.DoesNotContain("@scopeFactoryId", probe);
-        Assert.Contains("`update_time` > @cursor", probe); // 增量窗口保留,避免把"增量无新数据"误判为作用域异常
-        Assert.Contains("FROM b_examine_result", probe);
-        Assert.Contains("LIMIT 1", probe);                 // 只做存在性判断
     }
 
     [Fact]
@@ -283,8 +312,13 @@ public class MdpDbPullZeroRowGuardTests
             MdpDbPullExecutor.BuildScopeProbeSql(entity, isSqlServer: false, new MdpPullContext(), useKeyset: false));
     }
 
+    /// <summary>
+    /// 参数剥离必须与探针 SQL 是否发射租户谓词严格一致:
+    /// 带租户谓词就留 <c>@scopeTenantId</c>(否则参数缺失),不带就剔除(否则多绑一个 SQL 里没有的参数)。
+    /// <c>@scopeFactoryId</c> 两种情况下都剔除——探针的全部意义就是"去掉工厂口径再看一眼"。
+    /// </summary>
     [Fact]
-    public void ProbeParameters_ExcludeScopeParametersOnly()
+    public void ProbeParameters_KeepTenantWhenProbeEmitsTenantPredicate()
     {
         var parameters = new List<SugarParameter>
         {
@@ -294,7 +328,12 @@ public class MdpDbPullZeroRowGuardTests
             new("@windowFrom", DateTime.Parse("2026-08-01"))
         };
 
-        var stripped = MdpDbPullExecutor.StripScopeParameters(parameters);
+        var kept = MdpDbPullExecutor.StripScopeParameters(parameters, keepTenantParameter: true);
+        Assert.Equal(3, kept.Count);
+        Assert.Contains(kept, p => p.ParameterName.Contains("scopeTenantId", StringComparison.OrdinalIgnoreCase));
+        Assert.DoesNotContain(kept, p => p.ParameterName.Contains("scopeFactoryId", StringComparison.OrdinalIgnoreCase));
+
+        var stripped = MdpDbPullExecutor.StripScopeParameters(parameters, keepTenantParameter: false);
 
         Assert.Equal(2, stripped.Count);
         Assert.DoesNotContain(stripped, p => p.ParameterName.Contains("scope", StringComparison.OrdinalIgnoreCase));

+ 36 - 13
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpDbPullExecutor.cs

@@ -161,7 +161,7 @@ public sealed class MdpDbPullExecutor : IMdpSourcePullExecutor, ITransient
         var scopeMismatch = IsScopeMismatch(
             table.Rows.Count, ctx, tenantCol, factoryCol,
             sourceHasRowsOutsideScope: ShouldProbeScope(table.Rows.Count, ctx, tenantCol, factoryCol)
-                && await SourceHasRowsOutsideScopeAsync(scope, entity, ctx, isSqlServer, useKeyset, parameters, cancellationToken));
+                && await SourceHasRowsOutsideScopeAsync(scope, entity, ctx, isSqlServer, useKeyset, parameters, tenantCol, cancellationToken));
 
         var diagnostic = scopeMismatch
             ? $"{ScopeMismatchCode}: 源表 {entity.SourceTableName} 在 tenantId={ctx.TenantId}/factoryId={ctx.FactoryId} 作用域外仍有数据,"
@@ -211,13 +211,14 @@ public sealed class MdpDbPullExecutor : IMdpSourcePullExecutor, ITransient
     private static async Task<bool> SourceHasRowsOutsideScopeAsync(
         ISqlSugarClient scope, MdpEntity entity, MdpPullContext ctx,
         bool isSqlServer, bool useKeyset, List<SugarParameter> parameters,
-        CancellationToken cancellationToken)
+        string? tenantCol, CancellationToken cancellationToken)
     {
         cancellationToken.ThrowIfCancellationRequested();
         try
         {
-            var probeSql = BuildScopeProbeSql(entity, isSqlServer, ctx, useKeyset);
-            var probe = await scope.Ado.GetDataTableAsync(probeSql, StripScopeParameters(parameters));
+            var keepTenant = !string.IsNullOrWhiteSpace(tenantCol);
+            var probeSql = BuildScopeProbeSql(entity, isSqlServer, ctx, useKeyset, tenantCol);
+            var probe = await scope.Ado.GetDataTableAsync(probeSql, StripScopeParameters(parameters, keepTenant));
             return probe.Rows.Count > 0;
         }
         catch
@@ -227,17 +228,26 @@ public sealed class MdpDbPullExecutor : IMdpSourcePullExecutor, ITransient
         }
     }
 
-    /// <summary>探针复用正式查询的参数,但必须剔除作用域参数(探针 SQL 不再引用它们)。</summary>
-    internal static List<SugarParameter> StripScopeParameters(List<SugarParameter> parameters)
+    /// <summary>
+    /// 探针复用正式查询的参数,剔除探针 SQL 不再引用的作用域参数。
+    /// <para><paramref name="keepTenantParameter"/> 必须与
+    /// <see cref="BuildScopeProbeSql"/> 是否发射了租户谓词保持一致:
+    /// 探针带租户谓词时要留下 <c>@scopeTenantId</c>,否则参数会缺失;
+    /// 不带时要剔除,否则会多绑一个 SQL 里不存在的参数。</para>
+    /// <para><c>@scopeFactoryId</c> 恒剔除 —— 探针的全部意义就是"去掉工厂口径再看一眼"。</para>
+    /// </summary>
+    internal static List<SugarParameter> StripScopeParameters(
+        List<SugarParameter> parameters, bool keepTenantParameter = false)
         => parameters
-            .Where(p => !IsScopeParameter(p.ParameterName))
+            .Where(p => !IsStrippedScopeParameter(p.ParameterName, keepTenantParameter))
             .ToList();
 
-    private static bool IsScopeParameter(string? name)
+    private static bool IsStrippedScopeParameter(string? name, bool keepTenantParameter)
     {
         var n = (name ?? "").TrimStart('@', ':', '?');
-        return string.Equals(n, "scopeTenantId", StringComparison.OrdinalIgnoreCase)
-            || string.Equals(n, "scopeFactoryId", StringComparison.OrdinalIgnoreCase);
+        if (string.Equals(n, "scopeFactoryId", StringComparison.OrdinalIgnoreCase)) return true;
+        return !keepTenantParameter
+            && string.Equals(n, "scopeTenantId", StringComparison.OrdinalIgnoreCase);
     }
 
     private static string RequireSourceTable(MdpEntity entity)
@@ -361,17 +371,30 @@ public sealed class MdpDbPullExecutor : IMdpSourcePullExecutor, ITransient
     }
 
     /// <summary>
-    /// 作用域探针:与正式查询完全同一组增量/窗口/keyset 谓词,唯独**去掉**租户/工厂作用域谓词
-    /// 正式查询 0 行而探针有行 ⇒ 差异只可能来自作用域过滤,可据此判定"全量被作用域过滤掉"。
+    /// 作用域探针:与正式查询同一组增量/窗口/keyset 谓词,**保留租户谓词、只去掉工厂谓词**
+    /// 正式查询 0 行而探针有行 ⇒ 差异只可能来自工厂口径,可据此判定"本租户的数据被工厂过滤掉了"。
     /// 只做存在性判断(TOP 1 / LIMIT 1),不取数据。
+    ///
+    /// <para><b>租户谓词必须保留</b>。此前两个作用域谓词一起剥掉,探针退化成
+    /// <c>SELECT 1 FROM 源表 LIMIT 1</c> —— 在多租户共享同一张源表的模型下,
+    /// 它对**任何**租户都会命中别的租户的行,于是每一个合法空租户都被误报成 SCOPE_MISMATCH。
+    /// 实测该误报在共享库上累计 291 条,且全部落在源表本就 0 行的租户上,
+    /// 而真正被工厂谓词滤空的那两个租户反而一条都没报出来 —— 信号方向是反的。</para>
+    ///
+    /// <para>探针要回答的是「同一租户内,是否因工厂口径而取空」,
+    /// 不是「整张表有没有数据」。丢掉租户维度,这个问题就问不出来了。</para>
     /// </summary>
-    internal static string BuildScopeProbeSql(MdpEntity entity, bool isSqlServer, MdpPullContext ctx, bool useKeyset)
+    internal static string BuildScopeProbeSql(
+        MdpEntity entity, bool isSqlServer, MdpPullContext ctx, bool useKeyset, string? tenantCol = null)
     {
         var table = RequireSourceTable(entity);
         var predicates = useKeyset
             ? BuildKeysetPredicates(isSqlServer, ctx, out _)
             : BuildIncrementalPredicates(entity, isSqlServer, ctx, out _);
 
+        if (!string.IsNullOrWhiteSpace(tenantCol))
+            predicates.Add($"{QuoteIdent(tenantCol, isSqlServer)} = @scopeTenantId");
+
         var where = predicates.Count > 0 ? " WHERE " + string.Join(" AND ", predicates) : "";
         return isSqlServer
             ? $"SELECT TOP 1 1 AS probe FROM {table}{where}"