Преглед изворни кода

feat(mdp): 齐套 DWD 补发布证据,使合法空快照可判为健康

Rule03 的 Authority 是「按批次累积 + 末尾原子翻牌」的当前快照表。
发布语句在本轮零行时命中 0 行,在库里不留任何痕迹,于是两种截然不同的
情形在 DWD 中字面等同:

  ① 源侧本轮确实没有数据 —— 成功发布了一个空快照(合法)
  ② 发布压根没跑成 —— 旧批次被退休、新批次没被翻牌(故障)

Wave2 的健康门禁只能保守地把两者都判成 UNKNOWN、拦下恢复。对真实空租户
而言这是永久性的:它们的异常再也不会自动关闭,而调度器每轮都报成功。

修法是把这个隐式事实变成可观测事实:发布方法返回本批次发布后的当前行数,
连同批次号与工厂号一起写进运行日志的 summary_json。健康判定在当前批次数为 0 时
读这条证据——证据齐全且指向同一批次、当前行数为 0,才认定是合法空快照。

三处刻意为之:
· 当前行数用复核查询求得,不拿发布语句的影响行数充数——那条 UPDATE 是
  CASE WHEN...THEN 1 ELSE 0 END,影响行数同时含「本批翻成 1」与「旧批翻成 0」。
· 证据写在与 status='SUCCESS' 同一条 UPDATE 里,不存在「成功了但没有证据」的中间态;
  发布若抛错则走 FAILED 分支、证据保持为空,失败方向保守。
· marker 带工厂号,因为运行日志表没有工厂列,而快照的真实作用域是 (租户, 工厂);
  批次号格式有三种模板,解析它还原工厂会静默取错。

不用 dwd_rows 判空:实测它是两张 DWD 表的影响行数之和且 ODKU 命中计 2
(69 = 41 + 14×2),只覆盖 62.3% 的合法空轮次,且有两个租户 100% 覆盖不到
——它们的 ship_trans 恒有数,req_exam 恒为空。本地实测正好复现这一点:
租户 838257212780613 本轮 dwd_rows=18 而发布行数=0。

历史运行日志没有该字段,取出为 null → 维持原来的保守判定,升级前后行为一致。

本地 sandbox 实跑:
  797403760988230 / 824585161322565  dwd_rows=0 发布行数=0 → 合法空快照
  838257186181189                    dwd_rows=69 发布行数=41 → 非空快照
  838257212780613                    dwd_rows=18 发布行数=0 → 正是 dwd_rows 会误判的那种
  Rule03 不再出现在 RECOVERY_BLOCKED 中(判 HEALTHY,恢复放行);
  Rule01 仍正确拦截于 RULE01_MULTI_WRITER_UNTRUSTED,Wave2 未被破坏。
  DWD 当前快照:797 = 753 行、838 = 41 行,material_role 空值 0,各恰好一个当前批次。

零 DDL、无迁移脚本;summary_json 是既有 json 列,无人结构化解析它。
server 1.0.523 → 1.0.524
YY968XX пре 1 дан
родитељ
комит
a7328d10ca

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.523</AssemblyVersion>
-    <FileVersion>1.0.523</FileVersion>
-    <Version>1.0.523</Version>
+    <AssemblyVersion>1.0.524</AssemblyVersion>
+    <FileVersion>1.0.524</FileVersion>
+    <Version>1.0.524</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 158 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/S1SnapshotPublicationContractTests.cs

@@ -0,0 +1,158 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+/// <summary>
+/// S1 当前快照发布证据的契约。
+///
+/// <para><b>为什么需要这组测试</b>:Rule03 的 Authority 健康判定要区分两种「零个当前批次」——
+/// ①源侧本轮确实没数据,成功发布了一个空快照(合法,应放行恢复);
+/// ②发布压根没跑成(故障,必须拦截恢复)。
+/// DWD 表本身分不出来:发布语句在零行时命中 0 行,不留任何痕迹。</para>
+///
+/// <para>唯一的区分依据是生产侧写进运行日志的发布证据。它的可信度建立在<b>一条隐式的语句顺序</b>上:
+/// 发布必须严格早于「标记本轮成功」。这条顺序目前没有类型、事务或测试约束 ——
+/// 一旦有人把发布挪到标记成功之后,或在两者之间插入吞异常的步骤,
+/// 门禁会静默退化成假 HEALTHY,且没有任何报错。本文件就是钉死它的那颗钉子。</para>
+/// </summary>
+public class S1SnapshotPublicationContractTests
+{
+    private static string Source(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+
+        var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return File.ReadAllText(full);
+    }
+
+    private static string S1Service() =>
+        Source("Order", "S1MdpSyncTransformService.cs");
+
+    private static string Resolver() =>
+        Source("Service", "S8", "Rules", "Health", "S8MdpAuthorityHealthResolver.cs");
+
+    private static int At(string code, string anchor)
+    {
+        var i = code.IndexOf(anchor, StringComparison.Ordinal);
+        Assert.True(i >= 0, $"未找到锚点:{anchor}");
+        return i;
+    }
+
+    /// <summary>
+    /// <b>核心不变量</b>:发布必须早于「标记成功」。
+    ///
+    /// <para>只有这条成立,<c>status='SUCCESS'</c> 才蕴含「发布已完整跑完」,
+    /// 下游才敢把「SUCCESS + 发布证据 + 当前行数 0」当成合法空快照。</para>
+    ///
+    /// <para>三个入口(全量 / 贴源后 / 入站)各自都必须满足,逐个检查而不是只看第一个。</para>
+    /// </summary>
+    [Fact]
+    public void PublishMustHappenBeforeMarkingRunSuccessful()
+    {
+        var code = S1Service();
+
+        // BuildDwdAsync 内部:发布是 DWD 阶段的最后一步。
+        var dwdBody = code[At(code, "private async Task<int> BuildDwdAsync")..At(code, "private async Task<int> BuildS1KpiValuesAsync")];
+        Assert.True(At(dwdBody, "BuildDwdCommands") < At(dwdBody, "PublishCurrentSnapshotAsync"),
+            "发布必须在全部 DWD 写入之后,否则读者会看到没灌完的批次被标成 current");
+        Assert.True(At(dwdBody, "PublishCurrentSnapshotAsync") < At(dwdBody, "result.Publication ="),
+            "发布证据必须由发布本身产出,不能凭空构造");
+
+        // 三个入口:BuildDwdAsync(内含发布)必须早于 MarkTransformRunSuccessAsync。
+        var buildCalls = Indexes(code, "await BuildDwdAsync(");
+        var markCalls = Indexes(code, "await MarkTransformRunSuccessAsync(");
+        Assert.Equal(3, buildCalls.Count);
+        Assert.Equal(3, markCalls.Count);
+        for (var i = 0; i < 3; i++)
+        {
+            Assert.True(buildCalls[i] < markCalls[i],
+                $"第 {i + 1} 个入口把标记成功写在了发布之前 —— SUCCESS 将不再蕴含发布完成");
+        }
+    }
+
+    /// <summary>发布证据只在成功分支落盘;失败分支不得写,否则「失败但有证据」会骗过下游。</summary>
+    [Fact]
+    public void PublicationEvidenceIsWrittenOnlyOnSuccess()
+    {
+        var code = S1Service();
+
+        var successBody = code[At(code, "private async Task MarkTransformRunSuccessAsync")..At(code, "private async Task MarkTransformRunFailedAsync")];
+        Assert.Contains("summary_json=@SummaryJson", successBody);
+        Assert.Contains("BuildRunSummaryJson(result)", successBody);
+
+        var failedBody = code[At(code, "private async Task MarkTransformRunFailedAsync")..];
+        Assert.DoesNotContain("BuildRunSummaryJson", failedBody[..Math.Min(2000, failedBody.Length)]);
+    }
+
+    /// <summary>
+    /// 发布证据必须带批次号、工厂号与当前行数三项,缺一不可。
+    /// <para>批次号用于确认证据出自最近这一次成功;工厂号是因为运行日志表<b>没有工厂列</b>,
+    /// 而快照的真实作用域是 (租户, 工厂);当前行数才是「空还是非空」的答案。</para>
+    /// </summary>
+    [Fact]
+    public void PublicationMarkerCarriesBatchFactoryAndRowCount()
+    {
+        var code = S1Service();
+        var summaryBody = code[At(code, "private static string BuildRunSummaryJson")..At(code, "private static string ResolveKpiValueTable")];
+
+        Assert.Contains("publish = result.Publication is null", summaryBody);
+        Assert.Contains("batchId = result.Publication.BatchId", summaryBody);
+        Assert.Contains("factoryId = result.Publication.FactoryId", summaryBody);
+        Assert.Contains("currentRows = result.Publication.CurrentRows", summaryBody);
+
+        // 不再用字符串插值拼 JSON。
+        Assert.Contains("JsonSerializer.Serialize", summaryBody);
+    }
+
+    /// <summary>
+    /// 当前行数必须是<b>复核查询</b>得来,不能拿发布语句的影响行数充数。
+    /// <para>那条 UPDATE 是 <c>CASE WHEN … THEN 1 ELSE 0 END</c>,影响行数同时包含
+    /// 「本批被翻成 1」和「旧批被翻成 0」,与「当前有几行」不是一回事。</para>
+    /// </summary>
+    [Fact]
+    public void PublishedRowCountComesFromVerificationQuery_NotAffectedRows()
+    {
+        var code = S1Service();
+        var publishBody = code[At(code, "private static async Task<int> PublishCurrentSnapshotAsync")..At(code, "private async Task<int> BuildS1KpiValuesAsync")];
+
+        Assert.Contains("SELECT COUNT(*) FROM dwd_requirement_examine_detail", publishBody);
+        Assert.Contains("is_current_flag=1", publishBody);
+        Assert.Contains("calc_batch_id=@BatchId", publishBody);
+    }
+
+    /// <summary>
+    /// 消费侧:Resolver 必须读发布证据,且只在<b>证据齐全且指向同一批次</b>时才认定空快照合法。
+    /// <para>没有证据的历史行必须维持原来的保守判定 —— 升级前后行为一致,可灰度。</para>
+    /// </summary>
+    [Fact]
+    public void ResolverConsumesPublicationEvidence_AndStaysConservativeWithout()
+    {
+        var resolver = Resolver();
+
+        Assert.Contains("'$.publish.batchId'", resolver);
+        Assert.Contains("'$.publish.currentRows'", resolver);
+
+        var zeroBranch = resolver[At(resolver, "if (currentBatches == 0)")..At(resolver, "if (currentBatches > 1)")];
+
+        // 认定合法空快照的三个条件缺一不可。
+        Assert.Contains("!string.IsNullOrWhiteSpace(publishedBatchId)", zeroBranch);
+        Assert.Contains("string.Equals(publishedBatchId, lastSuccessBatchId", zeroBranch);
+        Assert.Contains("publishedRows == 0", zeroBranch);
+        Assert.Contains("S8SnapshotInvariant.Satisfied", zeroBranch);
+
+        // 缺证据时仍走 Unconfirmed(= UNKNOWN = 拦截恢复)。
+        Assert.Contains("S8SnapshotInvariant.Unconfirmed", zeroBranch);
+    }
+
+    private static List<int> Indexes(string code, string needle)
+    {
+        var list = new List<int>();
+        for (var i = 0; (i = code.IndexOf(needle, i, StringComparison.Ordinal)) >= 0; i += needle.Length)
+            list.Add(i);
+        return list;
+    }
+}

+ 90 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs

@@ -68,7 +68,7 @@ public class S1MdpSyncTransformService : ITransient
                 await ReportAsync(reportProgress, new S1MdpProgressUpdate(S1MdpRebuildStage.Standard, 2, 50, "标准化数据完成", result.StandardRows, S1MdpRebuildStage.Standard));
 
                 await ReportAsync(reportProgress, new S1MdpProgressUpdate(S1MdpRebuildStage.Dwd, 3, 50, "正在生成 DWD 明细"));
-                result.DwdRows = await BuildDwdAsync(scope, batchId, now, cancellationToken);
+                result.DwdRows = await BuildDwdAsync(scope, batchId, now, result, cancellationToken);
                 await ReportAsync(reportProgress, new S1MdpProgressUpdate(S1MdpRebuildStage.Dwd, 3, 65, "生成 DWD 明细完成", result.DwdRows, S1MdpRebuildStage.Dwd));
 
                 await ReportAsync(reportProgress, new S1MdpProgressUpdate(S1MdpRebuildStage.Kpi, 4, 65, "正在重算 KPI"));
@@ -122,7 +122,7 @@ public class S1MdpSyncTransformService : ITransient
         {
             await EnsureS1RuntimeObjectsAsync();
             result.StandardRows = await TransformStandardAsync(scope, batchId, now, cancellationToken);
-            result.DwdRows = await BuildDwdAsync(scope, batchId, now, cancellationToken);
+            result.DwdRows = await BuildDwdAsync(scope, batchId, now, result, cancellationToken);
             result.KpiRows = await BuildS1KpiValuesAsync(scope, batchId, now, cancellationToken);
             result.AtomicRows = await _atomicBuild.BuildOrderDeliveryDomainForAllDatesAsync(
                 scope.TenantId, scope.FactoryId, batchId, cancellationToken);
@@ -282,7 +282,7 @@ public class S1MdpSyncTransformService : ITransient
                 codes, batchId, scope.TenantId, fullRefresh, "S1_MDP_INBOUND", cancellationToken,
                 scope.FactoryId, requireMatchingSourceTenant: true);
             result.StandardRows = await TransformStandardAsync(scope, batchId, now, cancellationToken);
-            result.DwdRows = await BuildDwdAsync(scope, batchId, now, cancellationToken);
+            result.DwdRows = await BuildDwdAsync(scope, batchId, now, result, cancellationToken);
             result.KpiRows = await BuildS1KpiValuesAsync(scope, batchId, now, cancellationToken);
             result.AtomicRows = await _atomicBuild.BuildOrderDeliveryDomainForAllDatesAsync(
                 scope.TenantId, scope.FactoryId, batchId, cancellationToken);
@@ -386,7 +386,9 @@ public class S1MdpSyncTransformService : ITransient
         return total;
     }
 
-    private async Task<int> BuildDwdAsync(S1MdpRunScope scope, string batchId, DateTime now, CancellationToken cancellationToken)
+    private async Task<int> BuildDwdAsync(
+        S1MdpRunScope scope, string batchId, DateTime now,
+        S1MdpSyncTransformResult result, CancellationToken cancellationToken)
     {
         using var db = _db.CopyNew();
         db.Ado.CommandTimeOut = Math.Max(db.Ado.CommandTimeOut, 180);
@@ -401,7 +403,18 @@ public class S1MdpSyncTransformService : ITransient
         // 必须在所有 DWD 写入完成之后,否则读者会看到一个还没灌完的批次被标成 current。
         // 不计入 total —— total 语义是「本轮构建的 DWD 行数」,翻牌影响的行数属于发布动作,不是新建行。
         cancellationToken.ThrowIfCancellationRequested();
-        await PublishCurrentSnapshotAsync(db, scope, batchId);
+        var publishedRows = await PublishCurrentSnapshotAsync(db, scope, batchId);
+
+        // 发布证据随运行日志一起落盘(见 MarkTransformRunSuccessAsync)。
+        // 走到这里就意味着两条 DWD 写入与发布语句都已成功;若中途抛错,
+        // 调用方的 catch 会写 FAILED,本字段保持 null —— 失败方向是保守的。
+        result.Publication = new S1MdpSnapshotPublication
+        {
+            Table = "dwd_requirement_examine_detail",
+            BatchId = batchId,
+            FactoryId = scope.FactoryId,
+            CurrentRows = publishedRows
+        };
 
         return total;
     }
@@ -431,7 +444,12 @@ public class S1MdpSyncTransformService : ITransient
     /// 结果就是任务里点名要避免的「两个当前批次」永久共存。
     /// 故必须用 COALESCE(NULLIF(factory_id,0),1) 归一 —— 这也是本仓库通用的 factory 作用域写法。
     /// </summary>
-    private static async Task PublishCurrentSnapshotAsync(ISqlSugarClient db, S1MdpRunScope scope, string batchId)
+    /// <summary>
+    /// 发布当前快照,并返回本批次发布后的当前行数。
+    /// <para>返回 0 是<b>合法结果</b>——源侧本轮没有数据时,成功发布的就是一个空快照。
+    /// 调用方把这个数字记进运行日志,下游才能把「正常的空」与「没发布成」区分开。</para>
+    /// </summary>
+    private static async Task<int> PublishCurrentSnapshotAsync(ISqlSugarClient db, S1MdpRunScope scope, string batchId)
     {
         await db.Ado.ExecuteCommandAsync(
             """
@@ -444,6 +462,19 @@ public class S1MdpSyncTransformService : ITransient
             new SugarParameter("@BatchId", batchId),
             new SugarParameter("@TenantId", scope.TenantId),
             new SugarParameter("@FactoryId", scope.FactoryId));
+
+        // 复核发布结果而不是相信影响行数:ODKU 与 CASE 的影响行数都不等于「当前有几行」。
+        return await db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(*) FROM dwd_requirement_examine_detail
+             WHERE tenant_id=@TenantId
+               AND COALESCE(NULLIF(factory_id, 0), 1)=@FactoryId
+               AND calc_batch_id=@BatchId
+               AND is_current_flag=1
+            """,
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@TenantId", scope.TenantId),
+            new SugarParameter("@FactoryId", scope.FactoryId));
     }
 
     private async Task<int> BuildS1KpiValuesAsync(S1MdpRunScope scope, string batchId, DateTime now, CancellationToken cancellationToken)
@@ -1712,9 +1743,29 @@ public class S1MdpSyncTransformService : ITransient
         return string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
     }
 
+    /// <summary>
+    /// 运行摘要。改用 JsonSerializer 而非字符串插值:batchId 今天不可能含引号,
+    /// 但拼 JSON 是会被后来者继承的脆弱写法,且本次要加的 publish 是嵌套对象。
+    /// </summary>
     private static string BuildRunSummaryJson(S1MdpSyncTransformResult result)
     {
-        return $$"""{"batchId":"{{result.BatchId}}","stageRows":{{result.StageRows}},"standardRows":{{result.StandardRows}},"dwdRows":{{result.DwdRows}},"kpiRows":{{result.KpiRows}}}""";
+        return System.Text.Json.JsonSerializer.Serialize(new
+        {
+            batchId = result.BatchId,
+            stageRows = result.StageRows,
+            standardRows = result.StandardRows,
+            dwdRows = result.DwdRows,
+            kpiRows = result.KpiRows,
+            publish = result.Publication is null
+                ? null
+                : new
+                {
+                    table = result.Publication.Table,
+                    batchId = result.Publication.BatchId,
+                    factoryId = result.Publication.FactoryId,
+                    currentRows = result.Publication.CurrentRows
+                }
+        });
     }
 
     private static string ResolveKpiValueTable(int metricLevel)
@@ -1806,6 +1857,38 @@ public sealed class S1MdpSyncTransformResult
     public int DwdRows { get; set; }
     public int KpiRows { get; set; }
     public int AtomicRows { get; set; }
+
+    /// <summary>
+    /// 当前快照发布证据。为 null 表示本轮没走到发布这一步。
+    ///
+    /// <para><b>为什么需要它</b>:发布语句在本轮零行时命中 0 行,在库里不留任何痕迹,
+    /// 于是「源侧本轮确实没有数据」与「发布压根没跑成」在 DWD 里字面等同。
+    /// 下游的 Authority 健康判定因此只能保守地判 UNKNOWN、拦下恢复 ——
+    /// 哪怕这是一个完全正常的空快照。</para>
+    ///
+    /// <para>把发布结果记进运行日志,就把这个隐式事实变成了可观测事实:
+    /// 「本轮成功发布了批次 X,其中当前行数为 N(N 可以是 0)」。</para>
+    /// </summary>
+    public S1MdpSnapshotPublication? Publication { get; set; }
+}
+
+/// <summary>
+/// 一次当前快照发布的结果。与 <c>status='SUCCESS'</c> 写在同一条 UPDATE 里,
+/// 因此不存在「成功了但没有发布证据」的中间态。
+/// </summary>
+public sealed class S1MdpSnapshotPublication
+{
+    /// <summary>被发布为当前快照的表。</summary>
+    public string Table { get; set; } = string.Empty;
+
+    /// <summary>本轮批次号。读者据此确认「当前快照」出自哪一次运行。</summary>
+    public string BatchId { get; set; } = string.Empty;
+
+    /// <summary>发布作用域的工厂号。运行日志表本身没有工厂列,只能由这里承载。</summary>
+    public long FactoryId { get; set; }
+
+    /// <summary>发布后该批次的当前行数。<b>0 是合法值</b>,表示成功发布了一个空快照。</summary>
+    public int CurrentRows { get; set; }
 }
 
 internal sealed record S1MdpSqlCommand(string Sql, SugarParameter[] Parameters);

+ 38 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Health/S8MdpAuthorityHealthResolver.cs

@@ -69,7 +69,7 @@ public class S8MdpAuthorityHealthResolver : IS8AuthorityHealthResolver, ITransie
             var newerFailure = await QueryNewerTerminalFailureAsync(tenantId, spec.ProducerJobCode, anchor);
             var newerInFlight = await QueryNewerInFlightAsync(tenantId, spec.ProducerJobCode, anchor);
 
-            var (invariant, detail) = await ResolveSnapshotInvariantAsync(tenantId, spec, lastSuccess?.BatchId);
+            var (invariant, detail) = await ResolveSnapshotInvariantAsync(tenantId, spec, lastSuccess);
 
             return S8AuthorityHealthEvaluator.Evaluate(new S8AuthorityObservation
             {
@@ -124,7 +124,9 @@ public class S8MdpAuthorityHealthResolver : IS8AuthorityHealthResolver, ITransie
     {
         const string sql =
             """
-            SELECT r.batch_id AS BatchId, r.status AS Status, r.start_time AS StartTime, r.end_time AS EndTime
+            SELECT r.batch_id AS BatchId, r.status AS Status, r.start_time AS StartTime, r.end_time AS EndTime,
+                   JSON_UNQUOTE(JSON_EXTRACT(r.summary_json, '$.publish.batchId'))     AS PublishedBatchId,
+                   JSON_EXTRACT(r.summary_json, '$.publish.currentRows')               AS PublishedCurrentRows
             FROM mdp_transform_run_log r
             WHERE r.job_code = @JobCode
               AND r.tenant_id = @TenantId
@@ -207,8 +209,10 @@ public class S8MdpAuthorityHealthResolver : IS8AuthorityHealthResolver, ITransie
     /// 是拦截而非放行,失败方向安全。</para>
     /// </summary>
     private async Task<(string Invariant, string? Detail)> ResolveSnapshotInvariantAsync(
-        long tenantId, S8AuthoritySpec spec, string? lastSuccessBatchId)
+        long tenantId, S8AuthoritySpec spec, RunRow? lastSuccess)
     {
+        var lastSuccessBatchId = lastSuccess?.BatchId;
+
         if (!string.Equals(spec.AuthorityKind, S8AuthorityKind.PublishedSnapshot, StringComparison.Ordinal))
             return (S8SnapshotInvariant.NotApplicable, null);
 
@@ -232,8 +236,32 @@ public class S8MdpAuthorityHealthResolver : IS8AuthorityHealthResolver, ITransie
         var currentBatches = batch?.CurrentBatches ?? 0;
 
         if (currentBatches == 0)
+        {
+            // 零个当前批次有两种截然不同的成因,DWD 表本身分不出来:
+            //   ① 源侧本轮确实没有数据 —— 发布语句命中 0 行,成功发布了一个空快照(合法)
+            //   ② 发布压根没跑成 —— 上一批次被退休了,新批次没被翻牌(故障)
+            // 生产侧的发布证据是唯一能区分二者的东西,且它与 status='SUCCESS' 写在同一条 UPDATE 里,
+            // 不存在「成功了但没有证据」的中间态。
+            var publishedBatchId = lastSuccess?.PublishedBatchId;
+            var publishedRows = lastSuccess?.PublishedCurrentRows;
+
+            if (!string.IsNullOrWhiteSpace(publishedBatchId)
+                && string.Equals(publishedBatchId, lastSuccessBatchId, StringComparison.Ordinal)
+                && publishedRows == 0)
+            {
+                return (S8SnapshotInvariant.Satisfied, null);
+            }
+
+            // 没有发布证据(历史行,或生产侧尚未升级)→ 维持保守判定,与升级前行为完全一致。
+            var why = string.IsNullOrWhiteSpace(publishedBatchId)
+                ? "最近一次成功运行没有留下发布证据"
+                : $"发布证据指向批次 {publishedBatchId}(当前行数 {publishedRows?.ToString() ?? "未知"})," +
+                  $"与最近一次成功运行的批次 {lastSuccessBatchId} 不一致";
+
             return (S8SnapshotInvariant.Unconfirmed,
-                $"{table} 在租户 {tenantId} 下当前批次数为 0(合法空快照与发布失败在库里无法区分)");
+                $"{table} 在租户 {tenantId} 下当前批次数为 0,且{why}" +
+                "(合法空快照与发布失败在库里字面等同,不猜)");
+        }
 
         if (currentBatches > 1)
             return (S8SnapshotInvariant.Violated,
@@ -283,6 +311,12 @@ public class S8MdpAuthorityHealthResolver : IS8AuthorityHealthResolver, ITransie
         public string? Status { get; set; }
         public DateTime? StartTime { get; set; }
         public DateTime? EndTime { get; set; }
+
+        /// <summary>生产侧写入的发布证据;历史行没有该字段,取出为 null。</summary>
+        public string? PublishedBatchId { get; set; }
+
+        /// <summary>发布后的当前行数。0 是合法值(成功发布了一个空快照)。</summary>
+        public int? PublishedCurrentRows { get; set; }
     }
 
     /// <summary>快照校验投影行。</summary>