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

fix(s8): resolve exception flow tenant context and generic handler role

Background job 场景下 _userManager.TenantId 恒为 0(无 HttpContext),导致 S8 自动
异常建单起流时流程定义选择与审批人解析全部失败,只留下"有实例、零任务"的孤儿。

FlowEngine:
- 新增 StartFlow(input, trustedTenantId) 受信任重载;不改 StartFlowInput DTO,
  避免把后台上下文问题变成客户端可声明租户的信任边界风险。
- 流程定义选择由"TenantId 仅参与 ORDER BY"改为硬 WHERE 过滤:只允许本租户 +
  全局流程进入候选,其他租户的克隆不再可能被串用。
- ResolveApprovers 改为显式 effectiveTenantId 参数,Role Code→RoleId 与
  RoleId→User 两条分支统一使用它,方法体内不再读 _userManager.TenantId。
- HTTP 人工路径(Approve/Reject/Escalate/退回/超时推进)行为不变。

S8 调用点:CreateFromHitAsync / CreateFromWatchAsync / 手工提报统一传 entity.TenantId;
UpgradeAsync 传方法已持有的 tenantId。

角色(1.0.477.sql,仅 UAT 租户):新建 ROLE_S8_EXCEPTION_HANDLER 作为 S8 跨 S1-S7
通用异常受理角色,绑定 UATExceptionA,并把 UATA-TB001 的异常提报节点从默认租户物理
RoleId 1329908000150 改为角色 Code,使全局流程真正具备跨租户复用能力。不挂载任何
菜单权限。其余流程克隆保持原状(对应租户尚无该角色成员,另行处理)。

新增 17 个源码契约测试;AiDOP 全量 1560 passed / 0 failed。

server 1.0.477
YY968XX 3 дней назад
Родитель
Сommit
e9256eca04

+ 6 - 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.476</AssemblyVersion>
-    <FileVersion>1.0.476</FileVersion>
-    <Version>1.0.476</Version>
+    <AssemblyVersion>1.0.477</AssemblyVersion>
+    <FileVersion>1.0.477</FileVersion>
+    <Version>1.0.477</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -661,6 +661,9 @@
     <None Update="UpdateScripts\1.0.473.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.477.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 47 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.477.sql

@@ -0,0 +1,47 @@
+-- =============================================================================
+-- S8-EXCEPTION-FLOW-TENANT-CONTEXT-1
+-- 目的:为 S8 跨 S1-S7 场景共用的 EXCEPTION_REPORT/TB001「异常提报」节点建立真正
+--       跨模块通用的异常受理角色,替换此前误绑的 S1 专属角色 ROLE_S1_EXCEPTION_HANDLER
+--       (该角色系 1.0.193.sql 为「S1 产销协同/订单交付异常」创建,见 S8 Flow Role 审计:
+--       EXCEPTION_REPORT 流程在该迁移发生前已跨 S1-S7 场景共用,绑定 S1 专属角色属误配)。
+-- 范围:本批只保证 UAT 租户(838257186181189)闭环。其余流程克隆
+--       (默认租户全局 TB001 / UATB-TB001 / DEMO-TB001)保持原状,不在本批处理——
+--       这些租户尚无对应的 ROLE_S8_EXCEPTION_HANDLER 成员,盲目改绑只会把失败模式
+--       从「角色查不到人」平移成「角色本身不存在」,不构成修复。
+-- 安全:全幂等(NOT EXISTS 守卫 + FlowJson 唯一子串 REPLACE);
+--       不删除 ROLE_S1_EXCEPTION_HANDLER / s1_exception_handler;不动其他 3 份流程克隆的 FlowJson;
+--       不动历史 ApprovalFlowInstance / ApprovalFlowTask(StartFlow 用 FlowJsonSnapshot,
+--       改定义只影响之后新发起的实例)。
+-- 权限边界:新角色仅作 Flow Engine 审批身份,不插入任何 SysRoleMenu 行,不携带菜单/按钮/API 权限。
+-- =============================================================================
+
+-- 1) 新建角色 ROLE_S8_EXCEPTION_HANDLER / S8异常处理员(UAT 租户,幂等)
+INSERT INTO SysRole (Id, Name, Code, OrderNo, DataScope, Remark, Status, TenantId, CreateTime)
+SELECT 1329908000160, 'S8异常处理员', 'ROLE_S8_EXCEPTION_HANDLER', 800, 4,
+       'S8 跨 S1-S7 场景通用异常受理角色(S8-EXCEPTION-FLOW-TENANT-CONTEXT-1);仅 Flow Engine 审批身份,不挂载菜单权限',
+       1, 838257186181189, NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM SysRole
+  WHERE Id = 1329908000160 OR (Code = 'ROLE_S8_EXCEPTION_HANDLER' AND TenantId = 838257186181189)
+);
+
+-- 2) 绑定 UATExceptionA(838259724726341) → ROLE_S8_EXCEPTION_HANDLER(幂等)
+INSERT INTO SysUserRole (Id, UserId, RoleId)
+SELECT 1329908000161, 838259724726341, 1329908000160
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM SysUserRole WHERE UserId = 838259724726341 AND RoleId = 1329908000160
+);
+
+-- 3) UAT 专属流程克隆 UATA-TB001(Id=9206082610000001)的「异常提报」节点改绑:
+--    approverIds 从默认租户物理 RoleId「1329908000150」(S1专属)
+--    改为角色 Code「ROLE_S8_EXCEPTION_HANDLER」(由 FlowEngineService.ResolveApprovers
+--    按受信任租户 838257186181189 解析到本租户物理角色 → 本租户成员,不再跨租户借用);
+--    approverNames 展示标签同步更新。仅此一个流程行,不动 global TB001 / UATB-TB001 / DEMO-TB001。
+--    唯一子串 REPLACE:第二次执行时原串已不存在 → 自然 no-op(幂等)。
+UPDATE ApprovalFlow
+SET FlowJson = REPLACE(
+      REPLACE(FlowJson, '"approverIds":"1329908000150"', '"approverIds":"ROLE_S8_EXCEPTION_HANDLER"'),
+      '"approverNames":"S1异常处理员"', '"approverNames":"S8异常处理员"')
+WHERE Id = 9206082610000001 AND Code = 'UATA-TB001' AND BizType = 'EXCEPTION_REPORT';

+ 185 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S8ExceptionFlowTenantContextContractTests.cs

@@ -0,0 +1,185 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1 源码契约测试。
+///
+/// <para>背景:<c>FlowEngineService</c> 全程依赖 <c>SqlSugarRepository&lt;T&gt;</c>,
+/// 裸 xUnit 进程无法实例化(无参构造触发 Furion.App 静态构造,在无宿主进程中抛
+/// <c>TypeInitializationException</c>),因此本仓对这一层历来采用源码契约测试
+/// (grep 断言代码形状),而非行为级 DB 集成测试——本文件延续该既有惯例。</para>
+///
+/// <para>覆盖范围:验证 §14 六个场景要求的代码形状是否落地,而非跑真实数据库。
+/// 真正的行为级验证在运行态验收(Scheduler 真跑 GWR-S6-WO-DELAY)里完成。</para>
+/// </summary>
+public class S8ExceptionFlowTenantContextContractTests
+{
+    private static string FlowEngine() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
+
+    private static string S8ManualReportService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Service", "S8", "S8ManualReportService.cs"));
+
+    private static string S8TaskFlowService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Service", "S8", "S8TaskFlowService.cs"));
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join("/", parts));
+    }
+
+    // ── Case 1 / Case 16:HTTP 人工路径必须原样保留,不接受客户端可提交的 TenantId ──
+
+    [Fact]
+    public void PublicStartFlow_StillUsesUserManagerTenantId_NoRegressionForHttpCallers()
+    {
+        Assert.Contains(
+            "public Task<long> StartFlow(StartFlowInput input) => StartFlowCore(input, _userManager.TenantId);",
+            FlowEngine());
+    }
+
+    [Fact]
+    public void StartFlowInput_Dto_HasNoClientSettableTenantField()
+    {
+        var dto = File.ReadAllText(FindFile(
+            "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "Dto", "FlowEngineDtos.cs"));
+        var startInputBlock = dto[dto.IndexOf("public class StartFlowInput")..];
+        startInputBlock = startInputBlock[..startInputBlock.IndexOf("\n}\n")];
+        Assert.DoesNotContain("TenantId", startInputBlock);
+    }
+
+    // ── Case 2:受信任重载存在,供后台/系统调用方使用 ──
+
+    [Fact]
+    public void TrustedStartFlowOverload_Exists()
+    {
+        Assert.Contains(
+            "public Task<long> StartFlow(StartFlowInput input, long trustedTenantId) => StartFlowCore(input, trustedTenantId);",
+            FlowEngine());
+    }
+
+    // ── Case 3:租户隔离——Flow Definition 选择必须硬过滤,不能只在 ORDER BY 里降权 ──
+
+    [Fact]
+    public void FlowSelection_HardFiltersTenant_NotJustOrdersByIt()
+    {
+        var src = FlowEngine();
+        Assert.Contains("u.TenantId == effectiveTenantId || u.TenantId == null", src);
+        // 回归防护:旧的「只在 ORDER BY 里区分租户、WHERE 不过滤」写法不得再出现。
+        Assert.DoesNotContain("u.TenantId == tenantId ? 1 : 0", src);
+    }
+
+    // ── Case 4:Global Fallback——WHERE 允许 TenantId IS NULL,UAT 没有专属流程时能落到全局流程 ──
+
+    [Fact]
+    public void FlowSelection_AllowsGlobalFlowAsFallback()
+    {
+        Assert.Contains("u.TenantId == effectiveTenantId || u.TenantId == null", FlowEngine());
+    }
+
+    // ── Case 5:ResolveApprovers 的 Role Code / RoleId 两条分支都必须用 effectiveTenantId,禁止再读 _userManager.TenantId ──
+
+    [Fact]
+    public void ResolveApprovers_Signature_TakesExplicitEffectiveTenantId()
+    {
+        Assert.Contains(
+            "private async Task<List<(long userId, string userName)>> ResolveApprovers(FlowProperties? props, long initiatorId, long effectiveTenantId)",
+            FlowEngine());
+    }
+
+    [Fact]
+    public void ResolveApprovers_Body_DoesNotReadUserManagerTenantId()
+    {
+        var src = FlowEngine();
+        var start = src.IndexOf("private async Task<List<(long userId, string userName)>> ResolveApprovers(");
+        Assert.True(start >= 0, "ResolveApprovers method not found");
+        // 方法体到下一个同级 private 方法(EvaluateGateway)之前
+        var end = src.IndexOf("private string EvaluateGateway(", start);
+        Assert.True(end > start, "Could not bound ResolveApprovers method body");
+        var body = src[start..end];
+
+        Assert.DoesNotContain("_userManager.TenantId", body);
+        Assert.Contains("r.TenantId == effectiveTenantId", body);
+        Assert.Contains("u.TenantId == effectiveTenantId", body);
+    }
+
+    // ── Case 6:角色缺失必须显式失败,不得回落默认租户角色(既有 throw 逻辑未被改动) ──
+
+    [Fact]
+    public void CreateTasksForNode_StillThrowsOnZeroApprovers_NoSilentFallback()
+    {
+        Assert.Contains(
+            "throw Oops.Oh($\"节点 [{node.Properties?.NodeName ?? nodeId}] 未配置审批人或审批人列表为空\");",
+            FlowEngine());
+    }
+
+    // ── 既有推进路径(Approve/超时自动通过/手动升级)不得被本次改动波及行为 ──
+
+    [Fact]
+    public void ManualEscalate_StillPassesUserManagerTenantId_UnchangedBehavior()
+    {
+        var src = FlowEngine();
+        var idx = src.IndexOf("public async Task Escalate(long taskId, string? comment)");
+        Assert.True(idx >= 0);
+        var nextMethod = src.IndexOf("public async Task Urge(long instanceId)", idx);
+        var body = src[idx..nextMethod];
+        Assert.Contains("_userManager.TenantId", body);
+    }
+
+    [Fact]
+    public void AutoEscalateTask_StillPassesUserManagerTenantId_PreExistingGapUnchangedNotFixed()
+    {
+        var src = FlowEngine();
+        var idx = src.IndexOf("private async Task AutoEscalateTask(");
+        Assert.True(idx >= 0);
+        var body = src[idx..(idx + 1500)];
+        Assert.Contains("_userManager.TenantId", body);
+    }
+
+    // ── §13 调用点迁移:S8 三处后台/受信任路径必须改走受信任重载,不得依赖 _userManager.TenantId ──
+
+    [Fact]
+    public void S8ManualReportService_TryStartIntakeFlowAsync_UsesTrustedOverloadWithEntityTenantId()
+    {
+        var src = S8ManualReportService();
+        var idx = src.IndexOf("private async Task TryStartIntakeFlowAsync(AdoS8Exception entity)");
+        Assert.True(idx >= 0);
+        var body = src[idx..(idx + 1200)];
+        Assert.Contains("}, entity.TenantId);", body);
+    }
+
+    [Fact]
+    public void S8TaskFlowService_UpgradeAsync_UsesTrustedOverloadWithExplicitTenantId()
+    {
+        var src = S8TaskFlowService();
+        var idx = src.IndexOf("public async Task<AdoS8Exception> UpgradeAsync(long id, long tenantId, long factoryId, string? remark)");
+        Assert.True(idx >= 0);
+        var body = src[idx..(idx + 1200)];
+        Assert.Contains("}, tenantId);", body);
+    }
+
+    // ── §13 HTTP 分类:其余 6 个调用点保持不变,未被误改为受信任重载 ──
+
+    [Theory]
+    [InlineData("FinishedWarehouse", "FqcTaskEntryService.cs")]
+    [InlineData("MaterialWarehouse", "IqcInspBillFlowService.cs")]
+    [InlineData("Manufacturing", "IpqcInspectionFlowService.cs")]
+    [InlineData("Manufacturing", "S6ProcessInspectionReviewService.cs")]
+    [InlineData("FinishedWarehouse", "FqcInspBillFlowService.cs")]
+    public void HttpTriggeredCallSites_StillUsePublicOverload_NoRegression(string subDir, string file)
+    {
+        var src = File.ReadAllText(FindFile(
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", subDir, file));
+        Assert.Contains("_flowEngine.StartFlow(new StartFlowInput", src);
+        // 不应出现受信任重载的两参数调用形态在这些 HTTP 触发的文件里
+        Assert.DoesNotContain("}, tenantId);", src);
+    }
+}

+ 5 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -479,6 +479,10 @@ public class S8ManualReportService : ITransient
 
     /// <summary>
     /// TB001 异常提报审批流:自动监控 + 主动提报后软触发,失败仅 warn 日志,不阻断建单。
+    /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:统一走 FlowEngineService 的受信任租户重载,
+    /// 传入 entity.TenantId(该异常自身已确认的归属租户,三个调用方——自动建单的后台 Job 路径
+    /// 与手工提报的 HTTP 路径——均已在建单前完成租户解析,此处直接复用,不再依赖
+    /// _userManager.TenantId 隐式取值;后台 Job 场景下 HttpContext 为 null 会导致其恒为 0。
     /// </summary>
     private async Task TryStartIntakeFlowAsync(AdoS8Exception entity)
     {
@@ -497,7 +501,7 @@ public class S8ManualReportService : ITransient
                     ["exceptionTypeCode"] = entity.ExceptionTypeCode ?? "",
                     ["sourceType"] = entity.SourceType ?? ""
                 }
-            });
+            }, entity.TenantId);
         }
         catch (Exception ex)
         {

+ 4 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs

@@ -244,6 +244,9 @@ public class S8TaskFlowService : ITransient
         if (!S8StatusRules.IsAllowedTransition(e.Status, "ESCALATED"))
             throw new S8BizException($"状态 {e.Status} 不可升级");
 
+        // S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:UpgradeAsync 本身已持有可信 tenantId(人工点击=HTTP 走
+        // S8TrustedScopeResolver;S8TimeoutAutoEscalationJob 自动升级=后台 Job 显式传参),统一走
+        // 受信任重载,不依赖 _userManager.TenantId(后台 Job 场景下恒为 0)。
         await _flowEngine.StartFlow(new StartFlowInput
         {
             BizType = "EXCEPTION_ESCALATION",
@@ -256,7 +259,7 @@ public class S8TaskFlowService : ITransient
                 ["sceneCode"] = e.SceneCode,
                 ["priorityLevel"] = e.PriorityLevel,
             }
-        });
+        }, tenantId);
 
         // 状态和时间线由 ExceptionEscalationBizHandler.OnFlowStarted 回调更新
         return await LoadAsync(id, tenantId, factoryId) ?? e;

+ 53 - 18
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs

@@ -70,9 +70,22 @@ public class FlowEngineService : ITransient
     // ═══════════════════════════════════════════
 
     /// <summary>
-    /// 发起流程
+    /// 发起流程(HTTP 人工调用)。租户上下文取自当前登录身份。
     /// </summary>
-    public async Task<long> StartFlow(StartFlowInput input)
+    public Task<long> StartFlow(StartFlowInput input) => StartFlowCore(input, _userManager.TenantId);
+
+    /// <summary>
+    /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:发起流程(受信任租户重载)。
+    /// 仅限已在调用方完成租户归属校验的后台/系统上下文使用(如 S8 自动异常建单、
+    /// S8TimeoutAutoEscalationJob 触发的自动升级)——<paramref name="trustedTenantId"/>
+    /// 必须来自调用方已持有的可信业务租户(如 S8 规则/异常自身的 TenantId),
+    /// 禁止从客户端可提交的输入直接透传到本方法。
+    /// 无 HttpContext 的后台 Job 场景下 <c>_userManager.TenantId</c> 恒为 0,
+    /// 不使用本重载会导致流程定义选择与审批人解析全部失败(详见 S8 Flow Role 审计)。
+    /// </summary>
+    public Task<long> StartFlow(StartFlowInput input, long trustedTenantId) => StartFlowCore(input, trustedTenantId);
+
+    private async Task<long> StartFlowCore(StartFlowInput input, long effectiveTenantId)
     {
         // S8-S1-EXCEPTION-FLOW-SYNC-FIX-1:流程定义是「全局配置」,不应受 SqlSugarFilter 的数据范围(DataScope)隔离。
         // ApprovalFlow 继承 EntityBaseOrgDel,会被数据范围过滤命中:
@@ -82,11 +95,15 @@ public class FlowEngineService : ITransient
         // → 此查询返回 null → 抛「未找到已发布流程定义」→ StartFlow 失败 → 上游静默吞、建单不起流。
         // ClearFilter() 清除该查询全部全局过滤(Org/Self 数据范围 + 软删);软删由 WHERE 显式 !IsDelete 补回;
         // ApprovalFlow 无租户过滤(EntityBase 未实现 ITenantIdFilter),不存在绕过租户隔离风险。
-        var tenantId = _userManager.TenantId;
+        //
+        // S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:TenantId 从「仅参与排序」改为「硬过滤」——
+        // 候选集合只允许「本租户流程」+「全局流程(TenantId IS NULL)」,其他租户的流程克隆
+        // (如 UATB-TB001/DEMO-TB001)绝不进入候选,避免跨租户流程定义串用。
         var flow = await _flowRep.AsQueryable()
             .ClearFilter()
-            .Where(u => u.BizType == input.BizType && u.IsPublished && !u.IsDelete)
-            .OrderBy(u => u.TenantId == tenantId ? 1 : 0, OrderByType.Desc)
+            .Where(u => u.BizType == input.BizType && u.IsPublished && !u.IsDelete
+                        && (u.TenantId == effectiveTenantId || u.TenantId == null))
+            .OrderBy(u => u.TenantId == effectiveTenantId ? 1 : 0, OrderByType.Desc)
             .OrderBy(u => u.Version, OrderByType.Desc)
             .FirstAsync() ?? throw Oops.Oh($"未找到业务类型 [{input.BizType}] 的已发布流程定义");
 
@@ -128,7 +145,7 @@ public class FlowEngineService : ITransient
             throw Oops.Oh("流程图开始节点未连接任何后继节点");
         foreach (var target in firstOutgoing)
         {
-            await ProcessNextNode(instance, flowData, target);
+            await ProcessNextNode(instance, flowData, target, effectiveTenantId);
         }
 
         await InvokeHandler(input.BizType, instance.Id, h => h.OnFlowStarted(input.BizId, instance.Id));
@@ -370,7 +387,8 @@ public class FlowEngineService : ITransient
                 ApproverIds = props.EscalationApproverIds,
                 ApproverNames = props.EscalationApproverNames,
             },
-            instance.InitiatorId);
+            instance.InitiatorId,
+            _userManager.TenantId);
 
         if (escalationApprovers.Count == 0)
             throw Oops.Oh("升级目标审批人列表为空");
@@ -575,13 +593,17 @@ public class FlowEngineService : ITransient
 
         await CancelPendingTasks(task.InstanceId, task.NodeId, task.Id);
 
+        // S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:AutoEscalateTask 由 FlowTimeoutJob 后台调用,无 UserManager 上下文,
+        // _userManager.TenantId 在此恒为 0——这是既有的、超出本次 S8 异常建单批次范围的独立问题(登记见 KNOWN-ISSUES),
+        // 本次只将其显式化(原先是 ResolveApprovers 内部隐式读取,行为不变),不在本批修复。
         var approvers = await ResolveApprovers(
             new FlowProperties
             {
                 ApproverType = nodeProps.EscalationApproverType,
                 ApproverIds = nodeProps.EscalationApproverIds,
             },
-            instance.InitiatorId);
+            instance.InitiatorId,
+            _userManager.TenantId);
 
         if (approvers.Count == 0) return;
 
@@ -661,7 +683,12 @@ public class FlowEngineService : ITransient
     /// - parallelGateway:Fork 并行分发;Join 等待所有前驱完成
     /// - userTask / 其他:创建任务
     /// </summary>
-    private async Task ProcessNextNode(ApprovalFlowInstance instance, ApprovalFlowItem flowData, string nextNodeId)
+    /// <summary>
+    /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:<paramref name="trustedTenantId"/> 仅由 <see cref="StartFlowCore"/>
+    /// 的受信任路径向下透传;<see cref="AdvanceToNext"/>(Approve/超时自动通过等既有推进路径)调用时不传,
+    /// 保持原有语义——由 <see cref="CreateTasksForNode"/> 内部回退到 <c>_userManager.TenantId</c>。
+    /// </summary>
+    private async Task ProcessNextNode(ApprovalFlowInstance instance, ApprovalFlowItem flowData, string nextNodeId, long? trustedTenantId = null)
     {
         var nextNode = flowData.Nodes.FirstOrDefault(n => n.Id == nextNodeId);
         if (nextNode == null)
@@ -688,7 +715,7 @@ public class FlowEngineService : ITransient
             var targetNodeId = EvaluateGateway(nextNode.Properties?.Conditions, flowData, nextNode.Id, bizData);
             instance.CurrentNodeId = targetNodeId;
             await _instanceRep.AsUpdateable(instance).UpdateColumns(i => new { i.CurrentNodeId }).ExecuteCommandAsync();
-            await ProcessNextNode(instance, flowData, targetNodeId);
+            await ProcessNextNode(instance, flowData, targetNodeId, trustedTenantId);
             return;
         }
 
@@ -712,7 +739,7 @@ public class FlowEngineService : ITransient
             await MarkNodeCompleted(instance.Id, nextNode);
             foreach (var target in outgoing)
             {
-                await ProcessNextNode(instance, flowData, target);
+                await ProcessNextNode(instance, flowData, target, trustedTenantId);
             }
             return;
         }
@@ -720,7 +747,7 @@ public class FlowEngineService : ITransient
         // userTask 或其他:创建任务
         instance.CurrentNodeId = nextNodeId;
         await _instanceRep.AsUpdateable(instance).UpdateColumns(i => new { i.CurrentNodeId }).ExecuteCommandAsync();
-        await CreateTasksForNode(instance, flowData, nextNodeId);
+        await CreateTasksForNode(instance, flowData, nextNodeId, trustedTenantId);
     }
 
     /// <summary>
@@ -792,12 +819,15 @@ public class FlowEngineService : ITransient
         return lastLog?.OperatorId > 0 ? lastLog.OperatorId : null;
     }
 
-    private async Task CreateTasksForNode(ApprovalFlowInstance instance, ApprovalFlowItem flowData, string nodeId)
+    private async Task CreateTasksForNode(ApprovalFlowInstance instance, ApprovalFlowItem flowData, string nodeId, long? trustedTenantId = null)
     {
         var node = flowData.Nodes.FirstOrDefault(n => n.Id == nodeId)
             ?? throw Oops.Oh($"FlowJson 中未找到节点 [{nodeId}]");
 
-        var approvers = await ResolveApprovers(node.Properties, instance.InitiatorId);
+        // S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:trustedTenantId 仅由 StartFlowCore 的受信任路径透传;
+        // 其余既有推进路径(Approve/ReturnToPrev/超时自动通过等)不传,回退到 _userManager.TenantId,行为不变。
+        var effectiveTenantId = trustedTenantId ?? _userManager.TenantId;
+        var approvers = await ResolveApprovers(node.Properties, instance.InitiatorId, effectiveTenantId);
         if (approvers.Count == 0)
             throw Oops.Oh($"节点 [{node.Properties?.NodeName ?? nodeId}] 未配置审批人或审批人列表为空");
 
@@ -898,7 +928,12 @@ public class FlowEngineService : ITransient
         await _taskRep.AsUpdateable(paired).ExecuteCommandAsync();
     }
 
-    private async Task<List<(long userId, string userName)>> ResolveApprovers(FlowProperties? props, long initiatorId)
+    /// <summary>
+    /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:<paramref name="effectiveTenantId"/> 是本方法内所有租户相关判断
+    /// 的唯一真值源——Role Code → RoleId 查询、RoleId → 用户查询,一律使用它,不得再读 _userManager.TenantId。
+    /// 调用方负责传入正确值:HTTP 场景传 <c>_userManager.TenantId</c>;受信任后台场景传调用方已持有的业务租户。
+    /// </summary>
+    private async Task<List<(long userId, string userName)>> ResolveApprovers(FlowProperties? props, long initiatorId, long effectiveTenantId)
     {
         if (props == null || string.IsNullOrWhiteSpace(props.ApproverType))
             return new List<(long, string)>();
@@ -934,7 +969,7 @@ public class FlowEngineService : ITransient
             {
                 var codeRoleIds = await _userRep.Context.Queryable<SysRole>()
                     .ClearFilter()
-                    .Where(r => r.TenantId == _userManager.TenantId
+                    .Where(r => r.TenantId == effectiveTenantId
                                 && r.Status == StatusEnum.Enable
                                 && codes.Contains(r.Code))
                     .Select(r => r.Id)
@@ -953,10 +988,10 @@ public class FlowEngineService : ITransient
             // SysUser 继承 EntityBaseTenantOrg(→EntityBaseOrg),发起人 DataScope=Self 时被「CreateUserId==发起人」过滤、
             // Dept/DeptChild 时被 OrgId 过滤,会把角色成员(甚至发起人自己,CreateUserId 可能为 NULL)过滤掉
             // → 审批人列表为空 → ProcessNextNode 抛错、不建任务、实例悬挂。
-            // ClearFilter() 清除数据范围过滤;显式补回租户隔离(TenantId==当前登录租户),等价于原全局租户过滤,无跨租户泄漏。
+            // ClearFilter() 清除数据范围过滤;显式补回租户隔离(TenantId==effectiveTenantId),等价于原全局租户过滤,无跨租户泄漏。
             var users = await _userRep.AsQueryable()
                 .ClearFilter()
-                .Where(u => userIds.Contains(u.Id) && u.TenantId == _userManager.TenantId)
+                .Where(u => userIds.Contains(u.Id) && u.TenantId == effectiveTenantId)
                 .ToListAsync();
             return users.Select(u => (u.Id, u.RealName ?? "")).ToList();
         }