Prechádzať zdrojové kódy

feat(s0): 增加主数据标准层自动刷新与命令超时放宽

在此之前标准层刷新服务全仓唯一调用方是运维控制器,零定时、零作业、零调度;
运行日志一百五十六条的触发类型百分之百是手工。后果是覆盖面完全取决于有没有
人点过:九十二个「源有数据」的租户与维度组合里,二十八个目标表是零行;默认
租户的七个对象从未刷过一次。消费者一旦迁到标准层,这些租户会直接看到空列表。

新增按小时触发的作业,逐租户与维度串行跑全量替换。租户来自租户注册表的启用
项,维度来自定义目录再减去已停用的入站配置,两者都是动态枚举,作业里不维护
第二份清单——契约测试会检查作业源码里不出现任何维度键的字面量。

与其它模块的作用域目录有一处刻意的差异:不排除默认租户。那几个目录都写了
排除默认租户,S0 照抄会造成实际缺口——默认租户在图片类型与六张质检字典里
共有二十八行真实数据,却因为现有接口对它结构性不可达而从未被刷过:授权特性
只放行超管,而租户作用域又拒绝超管加主租户的组合,两道门的交集是空集。本轮
补刷时实测确认,默认租户七个对象全部返回四百,其余二十一个组合全部补刷成功。
定时作业不经过这两道门,正好补上这个洞。

逐组合调用而不是整租户一次,有三个理由:运行日志没有维度键的独立列,键只存在
于汇总 JSON 里,一组合一行才能让它可查;单个维度失败不会把整租户的状态拖成
失败;而且与现状天然一致,既有一百五十六条日志里一百五十五条本来就是单键。

失败隔离分两层。维度级本来就有,物化器内部捕获异常并把该维度置为失败;组合级
由本作业提供,一个组合炸掉不阻断其余组合。取消异常一律向上抛,否则运行日志会
永久停在运行中。失败时的快照语义保持不变:事务回滚、目标表停在上一轮完整快照,
绝不清空。

严格串行,不叠加任何并行度。这不是保守:数据库客户端是单例,而维度写入走显式
事务,在同一实例上并行开事务是明确的踩雷点;主库连接池上限只有二十。契约测试
会检查作业源码里不出现并行原语。

同时放宽物化区间的命令超时。全局值是三十秒,对本管线不够用:实测物料维度单次
平均一百三十二秒、最大一千一百七十秒,物料清单桥的同库集合式插入单条就要二十
七秒,紧贴三十秒线,已经实际炸过三次。手工逐个刷时人会重试所以问题被掩盖,
自动刷新上线后会变成每拍稳定失败。这里只在物化区间内临时放宽、退出时还原,
异常路径同样还原,不改全局常量、不影响其它模块,范式取自库存同步服务里的现成
写法。

本轮已补刷二十一个缺口,覆盖率从六十三升到八十五,剩余七个恰好是默认租户那
一组,只有本作业能补。作业本身尚未运行态验证——重启被其它实例的残留冒烟槽
挡住,按纪律未自行清理。故消费者迁移第一波仍判为暂缓。
YY968XX 2 dní pred
rodič
commit
523764b4f3

+ 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.515</AssemblyVersion>
-    <FileVersion>1.0.515</FileVersion>
-    <Version>1.0.515</Version>
+    <AssemblyVersion>1.0.516</AssemblyVersion>
+    <FileVersion>1.0.516</FileVersion>
+    <Version>1.0.516</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 105 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Dim/S0DimContractTests.cs

@@ -1106,6 +1106,92 @@ public class S0DimContractTests
         Assert.Contains("SUM(CASE WHEN g.r_cnt = 0 THEN g.l_cnt ELSE 0 END)", sql, Ord);
     }
 
+    // ════════════════════════════════════════════════════════════════════════════
+    // Wave 0 · 自动刷新与租户硬门
+    // ════════════════════════════════════════════════════════════════════════════
+
+    [Fact]
+    public void AutoRefreshJob_must_not_hardcode_any_dimension_key()
+    {
+        // 调度对象必须来自 S0DimCatalog.All,不得在 Job 里维护第二份 27 key 清单 ——
+        // 否则新增维度时会静默漏刷,而这种漏刷只在很久以后被消费者发现。
+        var code = StripComments(
+            ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/Job/S0DimAutoRefreshJob.cs"));
+        foreach (var key in S0DimCatalog.All.Select(d => d.Key))
+            Assert.DoesNotContain($"\"{key}\"", code, Ord);
+        Assert.Contains("S0DimScopeCatalog", code, Ord);
+    }
+
+    [Fact]
+    public void AutoRefreshJob_must_isolate_failures_and_stay_serial()
+    {
+        var src = StripComments(
+            ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/Job/S0DimAutoRefreshJob.cs"));
+        // Concurrent=false:Furion 保证同一作业不重入
+        Assert.Contains("Concurrent = false", src, Ord);
+        // 组合级 try/catch:一个 (租户,维度) 失败不得阻断其余组合
+        Assert.Contains("catch (Exception ex)", src, Ord);
+        // 取消必须向上抛,否则 run log 会永久停在 RUNNING
+        Assert.Contains("catch (OperationCanceledException)", src, Ord);
+        // triggerType=AUTO:run log 里要能区分「没人点」与「自动刷挂了」
+        Assert.Contains("triggerType: \"AUTO\"", src, Ord);
+        // 🔴 禁止任何并行原语:ISqlSugarClient 是单例且 dim 写入走显式事务
+        foreach (var parallel in new[] { "Parallel.For", "Task.WhenAll", "SemaphoreSlim" })
+            Assert.DoesNotContain(parallel, src, Ord);
+    }
+
+    [Fact]
+    public void ScopeCatalog_must_not_exclude_the_default_tenant()
+    {
+        // 🔴 S1MdpScopeCatalog / MdpRebuildScopeCatalog 都写了 Id != DefaultTenantId,
+        // 但 S0 照抄会造成实际缺口:默认租户在 ImageType 与 6 张 qms_* 里共有 28 行真实数据,
+        // 且现有 HTTP 入口对它结构性不可达(授权特性只放行超管,而租户作用域又拒绝
+        // 「超管 + 默认租户」),所以它从未被刷过一次。
+        var code = StripComments(
+            ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimScopeCatalog.cs"));
+        Assert.DoesNotContain("DefaultTenantId", code, Ord);
+        Assert.Contains("SysTenant", code, Ord);
+        // 必须剔除已停用 entity,否则自动作业每拍都为它产一条 FAILED,淹没真实告警
+        Assert.Contains("Status != 1", code, Ord);
+    }
+
+    [Fact]
+    public void Materializer_must_widen_command_timeout_for_materialization()
+    {
+        // 全局 CommandTimeOut = 30s 对本管线不够:实测 ITEM 单维度平均 132.7s,
+        // ITEM_BOM 的同库 INSERT...SELECT 单条 ~27s 紧贴 30s 线,已实际炸过三次。
+        // 手工刷时人会重试所以被掩盖;自动刷新会变成每拍稳定失败。
+        var code = StripComments(
+            ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimMaterializer.cs"));
+        Assert.Contains("LongCommandTimeoutScope", code, Ord);
+        Assert.Contains("MaterializeCommandTimeoutSeconds", code, Ord);
+        // 必须是 using 作用域(异常路径也还原),不得直接改全局常量
+        Assert.Contains("using var commandTimeout", code, Ord);
+        Assert.DoesNotContain("db.Ado.CommandTimeOut = 30", code, Ord);
+    }
+
+    [Fact]
+    public void ProductDesign_bom_and_routing_sql_must_carry_tenant_predicate_everywhere()
+    {
+        // 🔴 I-027:这两段裸 SQL 走 _db.Ado.SqlQueryAsync,完全绕过 SqlSugar 的 QueryFilter;
+        // 且该控制器是 [AllowAnonymous],匿名请求下 ITenantIdFilter 根本不注册。
+        // 实测修复前:料号 3124C0015 返回 1120 行(四个租户的拼盘),修复后按租户 32/31/22/22。
+        var src = ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/Order/ProductDesignService.cs");
+
+        // 七处谓词缺一不可(字符串在全文件唯一,无需切片定位)
+        Assert.Contains("WHERE ParentItem=@itemNum AND tenant_id=@tenantId", src, Ord);   // ① anchor
+        Assert.Contains("WHERE c.tenant_id=@tenantId", src, Ord);                          // ② recursive member
+        Assert.Contains("AND parentIm.tenant_id=@tenantId", src, Ord);                     // ③ 父表校验 join
+        Assert.Contains("AND im.tenant_id=@tenantId", src, Ord);                           // ④ 投影 join
+        Assert.Contains("AND pso.tenant_id=@tenantId", src, Ord);                          // ⑤ 投影 join
+        Assert.Contains("AND r.tenant_id=@tenantId", src, Ord);                            // ⑥ routing 驱动表
+        Assert.Contains("and p.tenant_id=@tenantId", src, Ord);                            // ⑦ routing join
+
+        // 参数必须真的传下去,否则谓词恒不成立、全部返回 0 行
+        Assert.Contains("new { itemNum, tenantId }", src, Ord);
+        Assert.Contains("var tenantId = _userManager.TenantId;", src, Ord);
+    }
+
     [Fact]
     public void Supplier_identity_must_be_tenant_and_supplier_code_only()
     {
@@ -1215,4 +1301,23 @@ public class S0DimContractTests
             Assert.NotEqual("mdp_stg_customer", d.StagingTable);
         }
     }
+
+    /// <summary>从仓库根读源文件做静态断言。测试进程工作目录在 bin 下,需向上回溯。</summary>
+    private static string ReadRepoFile(string relative)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "server")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        var path = Path.Combine(dir!.FullName, relative);
+        Assert.True(File.Exists(path), $"未找到 {path}");
+        return File.ReadAllText(path);
+    }
+
+    /// <summary>
+    /// 剥掉 <c>//</c> 与 <c>///</c> 注释行后再做断言。
+    /// 否则「不得出现 X」这类断言会被解释为什么不能出现 X 的注释本身打败 —— 已踩过一次。
+    /// </summary>
+    private static string StripComments(string source) =>
+        string.Join("\n", source.Split('\n').Where(l => !l.TrimStart().StartsWith("//", Ord)));
 }

+ 34 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimMaterializer.cs

@@ -67,6 +67,18 @@ public sealed class S0DimMaterializer : ITransient
             new("@BatchId", batchId)
         };
 
+        // 🔴 全局 CommandTimeOut = 30s(Admin.NET.Core/SqlSugar/SqlSugarSetup.cs:155),对本管线不够用。
+        //    实测(mdp_transform_run_log,2026-09-08):ITEM 单维度平均 132.7s、最大 1169.8s;
+        //    ITEM_BOM 的同库 INSERT ... SELECT 单条就要 ~27s,**紧贴 30s 线**,已实际炸过三次:
+        //      45747 ITEM     "The Command Timeout expired before the operation completed."
+        //      45941 ITEM_BOM "Connection must be Open; current state is Closed"
+        //      45945 ITEM_BOM "The Command Timeout expired before the operation completed."
+        //    手工逐个刷时人会重试,所以问题被掩盖;自动刷新上线后会变成每拍稳定失败。
+        //    这里只在物化区间内临时放宽,Dispose 时还原(异常路径同样还原),
+        //    不改全局常量、不影响其它模块 —— 范式取自
+        //    MaterialWarehouse/InventoryMdpSyncService.cs:627-644。
+        using var commandTimeout = new LongCommandTimeoutScope(_db, MaterializeCommandTimeoutSeconds);
+
         try
         {
             // [0] 前置校验:配置漂移与源侧数据质量,全部在动任何数据之前
@@ -153,6 +165,28 @@ public sealed class S0DimMaterializer : ITransient
         return result;
     }
 
+    /// <summary>
+    /// 物化区间的命令超时(秒)。取 900 与 <c>InventoryMdpSyncService</c> 一致:
+    /// 覆盖实测最慢的 ITEM(单维度最大 1169.8s 是整轮耗时,其中单条命令远小于此)并留足余量。
+    /// </summary>
+    private const int MaterializeCommandTimeoutSeconds = 900;
+
+    /// <summary>物化区间内临时放宽命令超时,Dispose 时还原原值(异常路径同样还原)。</summary>
+    private sealed class LongCommandTimeoutScope : IDisposable
+    {
+        private readonly ISqlSugarClient _db;
+        private readonly int _original;
+
+        public LongCommandTimeoutScope(ISqlSugarClient db, int seconds)
+        {
+            _db = db;
+            _original = db.Ado.CommandTimeOut;
+            db.Ado.CommandTimeOut = seconds;
+        }
+
+        public void Dispose() => _db.Ado.CommandTimeOut = _original;
+    }
+
     /// <summary>
     /// 断言 <c>mdp_entity</c> 的运行时配置与 definition 完全一致,返回 batch_size。
     /// 这一步把「配置漂移」变成显式失败 —— 否则 <c>PullAll</c> 可能悄悄拉到别的表 / 别的源。

+ 68 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimScopeCatalog.cs

@@ -0,0 +1,68 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
+
+/// <summary>
+/// 解析 S0 标准层自动刷新的作用域:哪些租户 × 哪些维度。
+///
+/// <para><b>租户权威 = <c>SysTenant</c>(<c>Status = Enable</c>)</b>,与
+/// <c>MdpRebuildScopeCatalog</c> / <c>S1MdpScopeCatalog</c> 同源。
+/// ⚠️ 表名是 PascalCase 的 <c>SysTenant</c>,不是 <c>sys_tenant</c>——查错名字会得到
+/// 「表不存在」并误判成「框架表不可达」。</para>
+///
+/// <para>🔴 <b>与 S1/MdpRebuild 的唯一差异:不排除默认租户。</b>
+/// 那两个 catalog 都写了 <c>x.Id != SqlSugarConst.DefaultTenantId</c>,
+/// 但 S0 照抄会造成实际数据缺口 —— 实测默认租户 <c>1300000000001</c> 在
+/// <c>ImageType</c> 与 6 张 <c>qms_*</c> 字典里共有 <b>28 行真实数据</b>,
+/// 却 <b>从未刷新过一次</b>(<c>mdp_transform_run_log</c> 中该租户的 S0 记录数 = 0)。
+/// 根因是现有 HTTP 入口对它<b>结构性不可达</b>:
+/// <c>AdoS0DimOpsAuthorizeAttribute</c> 只放行超管,而 <c>AdoS0TenantScope</c>
+/// 又拒绝「超管 + 目标租户 = 默认租户」的组合,两道门的交集是空集。
+/// 定时作业不经过这两道 HTTP 门,正好补上这个洞。</para>
+///
+/// <para><b>维度权威 = <see cref="S0DimCatalog.All"/></b>,动态遍历,不维护第二份 key 清单。
+/// 但要**减去 <c>mdp_entity.status != 1</c> 的已停用对象**:
+/// <c>S0DimMaterializer.AssertEntityContractAsync</c> 对停用对象是**抛异常**的,
+/// 手工触发时这是合理的 fail-fast(人主动停用了就该报错),
+/// 但自动作业每一拍都会为它产出一条 FAILED,把真实告警淹没。
+/// 所以「停用」要在入队前就与「失败」分开。</para>
+/// </summary>
+public sealed class S0DimScopeCatalog : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    /// <summary>注入本库客户端。</summary>
+    public S0DimScopeCatalog(ISqlSugarClient db) => _db = db;
+
+    /// <summary>自动刷新的作用域。</summary>
+    /// <param name="Tenants">启用租户(含默认租户,见类型注释)。</param>
+    /// <param name="Definitions">已启用的维度定义(已剔除 <c>mdp_entity.status != 1</c>)。</param>
+    /// <param name="DisabledKeys">被剔除的维度 key,供日志外化,不静默跳过。</param>
+    public sealed record Scope(
+        IReadOnlyList<long> Tenants,
+        IReadOnlyList<S0DimDefinition> Definitions,
+        IReadOnlyList<string> DisabledKeys);
+
+    /// <summary>解析当前应覆盖的租户与维度。</summary>
+    public async Task<Scope> ResolveAsync(CancellationToken ct = default)
+    {
+        var tenants = await _db.Queryable<SysTenant>()
+            .Where(x => x.Status == StatusEnum.Enable)
+            .Select(x => x.Id)
+            .ToListAsync(ct);
+
+        var disabled = (await _db.Queryable<MdpEntity>()
+                .Where(x => x.EntityCode.StartsWith("S0_") && x.Status != 1)
+                .Select(x => x.EntityCode)
+                .ToListAsync(ct))
+            .ToHashSet(StringComparer.Ordinal);
+
+        var defs = S0DimCatalog.All.Where(d => !disabled.Contains(d.EntityCode)).ToList();
+        var disabledKeys = S0DimCatalog.All.Where(d => disabled.Contains(d.EntityCode))
+            .Select(d => d.Key).ToList();
+
+        return new Scope(tenants.Distinct().OrderBy(x => x).ToList(), defs, disabledKeys);
+    }
+}

+ 129 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S0DimAutoRefreshJob.cs

@@ -0,0 +1,129 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// S0 主数据标准层自动刷新:逐租户跑一轮全量 FULL REPLACE(source → mdp_stg_s0_* → dim_*/ref_*/bridge_*)。
+///
+/// <para><b>为什么必须有它</b>:在此之前 <c>S0DimRefreshService</c> 全仓唯一调用方是
+/// <c>AdoS0DimRefreshController</c>,零 Cron、零 Job、零 Scheduler;
+/// <c>mdp_transform_run_log</c> 156 条记录的 <c>trigger_type</c> 100% 是 <c>MANUAL</c>。
+/// 后果是标准层的覆盖面完全取决于有没有人手工点过 ——
+/// 实测 92 个「源有数据」的 (对象 × 租户) 组合里 <b>28 个目标表为 0 行</b>,
+/// 默认租户 <c>1300000000001</c> 的 7 个对象**从未刷过一次**。
+/// 消费者一旦迁到标准层,这些租户会直接看到空列表。</para>
+///
+/// <para><b>租户与维度都是动态枚举,不维护第二份清单</b>:见 <see cref="S0DimScopeCatalog"/>——
+/// 租户取 <c>SysTenant.Status = Enable</c>(**含默认租户**,理由见该类注释),
+/// 维度取 <see cref="S0DimCatalog.All"/> 减去 <c>mdp_entity.status != 1</c> 的已停用对象。</para>
+///
+/// <para><b>逐 (租户 × 维度) 组合调用</b>,而不是整租户一次 <c>key=null</c>:
+/// ① <c>mdp_transform_run_log</c> 没有 <c>entity_key</c> 独立列,维度 key 只存在于
+///    <c>summary_json</c> 里;一组合一行 run log 才能让 <c>summary_json-&gt;&gt;'$[0].Key'</c> 可查、可索引;
+/// ② 单个维度失败不会把整租户的 run log status 拖成 FAILED;
+/// ③ 与现状天然一致 —— 既有 156 条 run log 里 155 条本来就是单 key。</para>
+///
+/// <para><b>失败隔离</b>分两层,都已存在、本类只是不破坏它们:
+/// ① 维度级 —— <c>S0DimMaterializer.RunAsync</c> 内部捕获异常并把该维度置 <c>FAILED</c>;
+/// ② 组合级 —— 本类逐 (租户,维度) try/catch,一个组合炸掉不阻断其余组合。
+/// 两层都不静默:维度级进 <c>mdp_transform_run_log.summary_json</c>,组合级进日志。</para>
+///
+/// <para><b>失败快照语义保持不变</b>:刷新失败 → 事务回滚 → 目标表停在上一轮完整快照,
+/// **绝不清空**(见 <c>S0DimMaterializer</c> 的阶段 III 注释)。</para>
+///
+/// <para><b>并发</b>:<c>Concurrent = false</c> 由 Furion 保证同一作业不并行;
+/// 本类内部对 (租户 × 维度) 组合**严格串行**,不叠加任何并行度。
+/// 这不是保守,是有实证约束的:<c>ISqlSugarClient</c> 是单例
+/// (<c>SqlSugarSetup.cs:74</c>),而 dim 写入走显式事务
+/// (<c>MdpStdFullReplace</c> 的 BeginTran/Commit),在同一 scope 上并行开事务是明确的踩雷点;
+/// 且主库连接池上限只有 20。—— 单租户全量最慢约 90s
+/// (<c>ITEM_BOM</c> 57199 行 27s + <c>ITEM</c> 21s + <c>ITEM_ROUTING_OP</c> 15s + 其余),
+/// 全租户一轮约数分钟,远小于调度间隔,无需并行。
+/// 并行反而会让多个租户同时对同一张目标表做 <c>DELETE ... WHERE tenant_id</c> + INSERT,
+/// 徒增锁竞争。</para>
+///
+/// <para><b>频率</b>:默认每小时一次(错开整点,避开 <c>job_s5_t8_kpi_refresh</c> 的 :00)。
+/// 该值只是**默认值** —— Furion 的 <c>[JobDetail]</c>/<c>[Cron]</c> 会自动持久化到
+/// <c>sys_job_detail</c> / <c>sys_job_trigger</c>,运行期可在作业管理页直接改 Cron 表达式,
+/// **无需改代码、无需发版**。故这里不再另建配置文件。
+/// 主数据变更频率低,且写路径校验类消费者已判定继续直读源表(不依赖标准层的 read-after-write),
+/// 小时级新鲜度对「看板筛选下拉」这类消费场景是够的。</para>
+/// </summary>
+[JobDetail("job_s0_dim_auto_refresh",
+    Description = "S0 主数据标准层自动刷新(逐租户 FULL:source → mdp_stg_s0_* → dim_*/ref_*/bridge_*)",
+    GroupName = "default",
+    Concurrent = false)]
+[Cron("15 * * * *",
+    TriggerId = "trigger_s0_dim_auto_refresh",
+    Description = "每小时第 15 分触发(5 字段:分 时 日 月 周)。错开整点以避开 S5/T8 作业。")]
+public class S0DimAutoRefreshJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    /// <summary>构造。</summary>
+    public S0DimAutoRefreshJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(S0DimAutoRefreshJob));
+    }
+
+    /// <inheritdoc />
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var catalog = scope.ServiceProvider.GetRequiredService<S0DimScopeCatalog>();
+        var refresh = scope.ServiceProvider.GetRequiredService<S0DimRefreshService>();
+
+        var sc = await catalog.ResolveAsync(stoppingToken);
+        if (sc.DisabledKeys.Count > 0)
+            _logger.LogInformation("S0DimAutoRefreshJob 跳过已停用维度 {Keys}(mdp_entity.status != 1)",
+                string.Join(",", sc.DisabledKeys));
+
+        int ok = 0, warned = 0, failed = 0;
+
+        foreach (var tenantId in sc.Tenants)
+        {
+            foreach (var def in sc.Definitions)
+            {
+                stoppingToken.ThrowIfCancellationRequested();
+                try
+                {
+                    // triggerType=AUTO:与手工触发在 run log 里可区分,
+                    // 便于回答「这次陈旧是没人点,还是自动刷挂了」。
+                    var result = await refresh.RefreshAsync(
+                        tenantId, key: def.Key, triggerType: "AUTO", ct: stoppingToken);
+
+                    if (result.Status == "FAILED") failed++;
+                    else if (result.Status == "SUCCESS_WITH_WARNING") warned++;
+                    else ok++;
+                }
+                catch (OperationCanceledException)
+                {
+                    // 应用停机 / 作业被取消:直接向上抛,让 Furion 记为取消,不吞。
+                    throw;
+                }
+                catch (Exception ex)
+                {
+                    // 组合级隔离:一个 (租户,维度) 炸掉不阻断其余组合,但必须留痕。
+                    failed++;
+                    _logger.LogError(ex, "S0DimAutoRefreshJob 组合失败 tenant={Tenant} key={Key}",
+                        tenantId, def.Key);
+                }
+            }
+        }
+
+        _logger.LogInformation("S0DimAutoRefreshJob 完成 {Payload}", JsonSerializer.Serialize(new
+        {
+            tenants = sc.Tenants.Count,
+            definitions = sc.Definitions.Count,
+            combinations = sc.Tenants.Count * sc.Definitions.Count,
+            ok, warned, failed,
+            skippedDisabled = sc.DisabledKeys.Count
+        }));
+    }
+}