Pārlūkot izejas kodu

fix(s7): enforce tenant-safe FQC initiator creation | server 1.0.552

S7 FQC 曾把不属于单据租户的用户写成 jyfzr,起流后被引擎照实
捕获为 ApprovalFlowInstance.InitiatorId;而 ResolveApprovers 的
Initiator 分支按环境租户过滤,导致该实例退回 N1 时永久解析不出
审批人。本次只封堵新增,不改动任何既有实例。

不变量:BusinessTenant == jyfzr 用户.TenantId == Initiator 用户.TenantId
三道防线(全部 fail closed,且都在业务写入/起流之前):
- Assign:校验 InspectorUserId,原实现完全不查用户
- Claim:校验 _userManager.UserId —— 超管切租户只改内存 TenantId
  并重签 token、不持久化,故 JWT 租户与 SysUser.TenantId 会背离
- GenerateInspBill / SubmitResult:校验业务行上已存的 jyfzr,
  拦住先于本守卫写入的存量坏数据再次起流

守卫落在 S7 业务边界,不改 FlowEngineService:引擎照实记录操作者
是正确行为,「谁可以成为该租户单据的负责人」是 S7 的业务规则。
平台权限不等于业务身份——超管除非真实属于该租户,不能作为其
FQC 负责人与发起人。

Runtime(R1-R10)未验证:后端受 sys_db_migration_log 版本簿记
阻塞无法启动,Chrome DevTools MCP 同时断链,两者均超本批授权,
不自行绕过。已验证项:plugin 编译 0 Error;30 条针对性契约测试
全过;插件全量 2872 passed / 1 failed(既有 S8 用例,与本批无关);
S7 域 ApprovalFlowInstance/Task/Log 16/42/49 与操作前基线一致;
特殊实例 840240525086789 四项快照未变。
YY968XX 6 dienas atpakaļ
vecāks
revīzija
368afef6b7

+ 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.551</AssemblyVersion>
-    <FileVersion>1.0.551</FileVersion>
-    <Version>1.0.551</Version>
+    <AssemblyVersion>1.0.552</AssemblyVersion>
+    <FileVersion>1.0.552</FileVersion>
+    <Version>1.0.552</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 286 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S7/S7FqcInitiatorTenantGuardTests.cs

@@ -0,0 +1,286 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S7;
+
+/// <summary>
+/// S7-FQC-INITIATOR-TENANT-GUARD-1 契约测试:阻止 S7 FQC 再产生跨租户 jyfzr / Initiator。
+///
+/// 要守住的不变量:
+/// <c>BusinessTenant == jyfzr 用户.TenantId == Instance.Initiator 用户.TenantId</c>。
+/// S7 契约是三者恒等(SubmitResult 的负责人门 + GenerateInspBill 的认领人门保证
+/// jyfzr == 当前用户 == N1 Assignee),故只要 jyfzr 恒同租户,Initiator 就恒同租户。
+///
+/// 三道防线:Assign → Claim → 起流边界(GenerateInspBill / SubmitResult)。
+///
+/// 沿用本仓既有做法——源码契约断言而非行为级集成测试:这些服务依赖
+/// ISqlSugarClient / UserManager / FlowEngineService,裸 xUnit 进程无法实例化。
+/// 真实 ALLOW/DENY 由本批 Runtime R1–R7 覆盖。
+/// </summary>
+public class S7FqcInitiatorTenantGuardTests
+{
+    private static string Guard() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "S7FqcInitiatorTenantGuard.cs"));
+
+    private static string TaskEntry() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcTaskEntryService.cs"));
+
+    private static string FlowService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowService.cs"));
+
+    private static string Engine() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
+
+    private static string FlowTaskService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
+
+    // ───────────── 守卫本体 ─────────────
+
+    /// <summary>§十七:不得只判「用户存在」,必须显式比较 SysUser.TenantId 与单据租户。</summary>
+    [Fact]
+    public void Guard_ComparesUserTenantAgainstBusinessTenant()
+    {
+        var g = Guard();
+        Assert.Contains("if (user.TenantId != businessTenantId)", g);
+        Assert.Contains("不属于当前单据租户", g);
+    }
+
+    /// <summary>存在性与启用状态也要判 —— 三者缺一都不能当负责人。</summary>
+    [Theory]
+    [InlineData("if (user == null)")]
+    [InlineData("if (user.Status != StatusEnum.Enable)")]
+    [InlineData("if (userId <= 0)")]
+    public void Guard_ChecksExistenceAndEnabled(string expected)
+    {
+        Assert.Contains(expected, Guard());
+    }
+
+    /// <summary>
+    /// 必须跨租户查用户(ClearFilter&lt;ITenantIdFilter&gt;)—— 否则跨租户用户表现为「查不到」,
+    /// 无法把「不存在」与「存在但不属于本租户」区分开,只能给出误导性文案。
+    /// </summary>
+    [Fact]
+    public void Guard_LooksUpUserAcrossTenantsOnPurpose()
+    {
+        var g = Guard();
+        Assert.Contains("ClearFilter<ITenantIdFilter>()", g);
+        Assert.Contains("db.Queryable<SysUser>()", g);
+    }
+
+    /// <summary>§二十七:文案不得泄漏 TenantId / RoleId / 内部过滤器。</summary>
+    [Theory]
+    [InlineData("TenantId=")]
+    [InlineData("RoleId")]
+    [InlineData("ITenantIdFilter}")]
+    public void Guard_MessageDoesNotLeakInternals(string leak)
+    {
+        var messages = string.Join("\n", Guard().Split('\n').Where(l => l.Contains("Oops.Oh(", StringComparison.Ordinal)));
+        Assert.DoesNotContain(leak, messages);
+    }
+
+    /// <summary>存量脏数据:jyfzr 可能根本不是用户 Id,必须显式拒绝而不是当 0 处理。</summary>
+    [Fact]
+    public void Guard_RejectsNonNumericLegacyOwnerValue()
+    {
+        var body = Slice(Guard(), "public static async Task EnsureBillOwnerTenantSafeAsync", "private sealed class GuardUser");
+        Assert.Contains("!long.TryParse(raw, out var ownerId) || ownerId <= 0", body);
+        Assert.Contains("检验负责人记录无效", body);
+        Assert.Contains("尚未指定检验负责人", body);
+    }
+
+    /// <summary>守卫只读不写 —— 任何 blocked 路径都不能产生副作用。</summary>
+    [Theory]
+    [InlineData("AsInsertable")]
+    [InlineData("AsUpdateable")]
+    [InlineData("ExecuteCommandAsync")]
+    [InlineData("Deleteable")]
+    public void Guard_PerformsNoMutation(string write)
+    {
+        Assert.DoesNotContain(write, CsCode(Guard()));
+    }
+
+    // ───────────── §二十九 Assign ─────────────
+
+    /// <summary>Assign 必须在写 jyfzr **之前**校验目标用户。</summary>
+    [Fact]
+    public void Assign_GuardsBeforeWritingOwner()
+    {
+        var body = Slice(TaskEntry(), "public async Task<object> Assign(", "/// <summary>优先级调整");
+        var guardAt = body.IndexOf("EnsureCanOwnFqcAsync", StringComparison.Ordinal);
+        var writeAt = body.IndexOf("UPDATE qms_fqcbj SET jyfzr", StringComparison.Ordinal);
+        Assert.True(guardAt > 0, "Assign 缺少租户守卫");
+        Assert.True(writeAt > guardAt, "守卫必须早于 jyfzr 写入");
+        Assert.Contains("EnsureCanOwnFqcAsync(_db, input.InspectorUserId, tenantId, \"指定检验负责人\")", body);
+    }
+
+    // ───────────── §三十 Claim ─────────────
+
+    /// <summary>Claim 必须校验**当前用户实体**的租户,而不是只看上下文租户等于单据租户。</summary>
+    [Fact]
+    public void Claim_GuardsCurrentUserEntityTenant()
+    {
+        var body = Slice(TaskEntry(), "public async Task<object> Claim(", "/// <summary>检验员调配");
+        var guardAt = body.IndexOf("EnsureCanOwnFqcAsync", StringComparison.Ordinal);
+        var writeAt = body.IndexOf("UPDATE qms_fqcbj SET jyfzr", StringComparison.Ordinal);
+        Assert.True(guardAt > 0, "Claim 缺少租户守卫");
+        Assert.True(writeAt > guardAt, "守卫必须早于 jyfzr 写入");
+        Assert.Contains("EnsureCanOwnFqcAsync(_db, _userManager.UserId, tenantId, \"当前用户\")", body);
+    }
+
+    /// <summary>
+    /// §十九/§二十:超管切租户后 token 的 TenantId claim 是目标租户、SysUser.TenantId 仍是默认租户。
+    /// 守卫比对的必须是 SysUser.TenantId(跨租户查出来的实体),不是 _userManager.TenantId。
+    /// </summary>
+    [Fact]
+    public void Claim_DoesNotTrustTokenTenantAsUserTenant()
+    {
+        var g = Guard();
+        // 守卫**实现**内不得出现「用 _userManager.TenantId 当作用户归属」的写法
+        Assert.DoesNotContain("_userManager", CsCode(g));
+        Assert.Contains("user.TenantId != businessTenantId", g);
+    }
+
+    // ───────────── §三十一 起流边界 ─────────────
+
+    /// <summary>GenerateInspBill 在 EnsureFlowAsync(起流)之前校验业务行上的 jyfzr。</summary>
+    [Fact]
+    public void GenerateInspBill_GuardsBeforeStartFlow()
+    {
+        var body = Slice(TaskEntry(), "public async Task<FqcTaskGenerateOutput> GenerateInspBill(", "private async Task EnsureFlowAsync");
+        var guardAt = body.IndexOf("EnsureBillOwnerTenantSafeAsync", StringComparison.Ordinal);
+        var startAt = body.IndexOf("EnsureFlowAsync(bill.Id", StringComparison.Ordinal);
+        Assert.True(guardAt > 0, "GenerateInspBill 缺少起流边界守卫");
+        Assert.True(startAt > guardAt, "守卫必须早于起流");
+    }
+
+    /// <summary>SubmitResult 在事务(含 StartFlow)之前校验 jyfzr。</summary>
+    [Fact]
+    public void SubmitResult_GuardsBeforeTransactionAndStartFlow()
+    {
+        var body = Slice(FlowService(), "public async Task<FqcFlowStateOutput> SubmitResult(", "/// <summary>主管审核通过");
+        var guardAt = body.IndexOf("EnsureBillOwnerTenantSafeAsync", StringComparison.Ordinal);
+        var tranAt = body.IndexOf("UseTranAsync", StringComparison.Ordinal);
+        var startAt = body.IndexOf("_flowEngine.StartFlow", StringComparison.Ordinal);
+        Assert.True(guardAt > 0, "SubmitResult 缺少起流边界守卫");
+        Assert.True(tranAt > guardAt, "守卫必须早于事务开启");
+        Assert.True(startAt > guardAt, "守卫必须早于 StartFlow");
+    }
+
+    /// <summary>业务租户必须来自业务上下文/业务行,不是 token 直接透传(§十)。</summary>
+    [Fact]
+    public void StartFlowBoundary_UsesBusinessTenant()
+    {
+        Assert.Contains("EnsureBillOwnerTenantSafeAsync(_db, apply.OwnerId, tenantId)", TaskEntry());
+        Assert.Contains("EnsureBillOwnerTenantSafeAsync(_db, jyfzrOwner, gateTenantId)", FlowService());
+    }
+
+    /// <summary>三道防线都在,形成 defense-in-depth(§十五)。</summary>
+    [Fact]
+    public void ThreeLayersOfDefenseArePresent()
+    {
+        var t = TaskEntry();
+        Assert.Equal(2, CountOf(t, "EnsureCanOwnFqcAsync"));            // Assign + Claim
+        Assert.Equal(1, CountOf(t, "EnsureBillOwnerTenantSafeAsync"));  // GenerateInspBill
+        Assert.Equal(1, CountOf(FlowService(), "EnsureBillOwnerTenantSafeAsync")); // SubmitResult
+    }
+
+    // ───────────── §二十四/§五十三 范围守卫 ─────────────
+
+    /// <summary>不得为 S7 改 FlowEngine:引擎照实记录操作者是正确行为,改它会扩散到全部 BizType。</summary>
+    [Theory]
+    [InlineData("S7_FQC_INSPBILL")]
+    [InlineData("FqcInspBillFlowConst")]
+    public void FlowEngine_HasNoS7SpecialCase(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, CsCode(Engine()));
+    }
+
+    /// <summary>引擎仍按「当前用户」捕获 Initiator —— 本批没有改这条语义。</summary>
+    [Fact]
+    public void FlowEngine_StillCapturesCurrentUserAsInitiator()
+    {
+        Assert.Contains("InitiatorId = _userManager.UserId,", Engine());
+    }
+
+    /// <summary>守卫不得改写业务语义:jyfzr 仍是 SysUser.Id,Initiator 仍 == jyfzr == 当前用户。</summary>
+    [Fact]
+    public void Contract_InitiatorEqualsOwnerEqualsCurrentUser_Preserved()
+    {
+        var f = FlowService();
+        Assert.Contains("当前用户不是该检验单负责人,无权提交检验结果", f);
+        Assert.Contains("jyfzr==当前用户==N1 Assignee 三者一致", f);
+        Assert.Contains("尚未认领或非认领人操作!", TaskEntry());
+    }
+
+    // ───────────── §四十四/§四十五 回归 ─────────────
+
+    /// <summary>S7 通用审批旁路守卫必须仍然生效。</summary>
+    [Fact]
+    public void GenericApprovalGuard_StillCoversS7()
+    {
+        var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
+        Assert.Contains("[\"S7_FQC_INSPBILL\"]", dict);
+    }
+
+    /// <summary>本批不得把 S7 加入 TenantStrictRoleBizTypes(属另一治理边界)。</summary>
+    [Fact]
+    public void StrictRoleGuard_StillNotOpenedForS7()
+    {
+        var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
+        Assert.DoesNotContain("S7_FQC_INSPBILL", set);
+    }
+
+    /// <summary>§四十七/§五十五:本批是 prevention,不得出现任何数据迁移/历史回写。</summary>
+    [Theory]
+    [InlineData("ApprovalFlowInstance")]
+    [InlineData("ApprovalFlowTask")]
+    [InlineData("ApprovalFlowLog")]
+    [InlineData("840240525086789")]
+    public void Guard_NeverTouchesFlowOrHistoricalData(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, CsCode(Guard()));
+    }
+
+    /// <summary>
+    /// **去注释**后的 C# 源码视图。这些文件的注释会成段引用被淘汰/被解释的写法
+    /// (如 FlowEngineService 注释里提到 S7_FQC_INSPBILL、守卫文档里提到 _userManager 与
+    /// ApprovalFlowInstance)来说明「为何如此设计」;裸文本 DoesNotContain 会把说明当成违规实现。
+    /// 断言「实现里不得出现 X」时一律用本视图;断言「注释里必须写明某约定」时用原文。
+    /// </summary>
+    private static string CsCode(string src)
+    {
+        var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
+        var kept = s.Split('\n').Where(l =>
+        {
+            var t = l.TrimStart();
+            return !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("///", StringComparison.Ordinal);
+        });
+        return string.Join("\n", kept);
+    }
+
+    private static int CountOf(string src, string needle)
+    {
+        int n = 0, i = 0;
+        while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
+        return n;
+    }
+
+    private static string Slice(string src, string from, string to)
+    {
+        var a = src.IndexOf(from, StringComparison.Ordinal);
+        Assert.True(a >= 0, $"未找到起点:{from}");
+        var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
+        return b > a ? src[a..b] : src[a..];
+    }
+
+    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));
+    }
+}

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcInspBillFlowService.cs

@@ -119,6 +119,12 @@ public class FqcInspBillFlowService : IDynamicApiController, ITransient
                 new SugarParameter("@bjbh", sourceBillNo), new SugarParameter("@t", gateTenantId));
             if (!string.Equals((jyfzrOwner ?? "").Trim(), _userManager.UserId.ToString(), StringComparison.Ordinal))
                 throw Oops.Oh("当前用户不是该检验单负责人,无权提交检验结果");
+
+            // S7-FQC-INITIATOR-TENANT-GUARD-1:起流边界。本方法在下面的事务里可能 StartFlow,
+            // 而 StartFlow 会把当前用户照实记成 Initiator;Initiator 的**读取**侧(ReturnToPrev→N1)
+            // 按环境租户过滤用户,因此一个不属于本租户的负责人会造出「退回 N1 时无人可解析」的实例。
+            // 上面的负责人门只保证 jyfzr == 当前用户,不保证这个人属于本租户 —— 这里补上。
+            await S7FqcInitiatorTenantGuard.EnsureBillOwnerTenantSafeAsync(_db, jyfzrOwner, gateTenantId);
         }
 
         var now = DateTime.Now;

+ 21 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcTaskEntryService.cs

@@ -156,6 +156,14 @@ public class FqcTaskEntryService : IDynamicApiController, ITransient
         var ids = NormalizeIds(input?.Ids);
         var tenantId = ResolveTenantOrThrow();
         await EnsureNotLockedAsync(ids, tenantId); // G4:起流后冻结负责人
+
+        // S7-FQC-INITIATOR-TENANT-GUARD-1:认领人必须**真实属于**本单据租户。
+        // 不能因为「当前上下文租户 == 单据租户」就认定当前用户是该租户用户 ——
+        // 超管切租户后 token 的 TenantId claim 是目标租户,而 SysUser.TenantId 仍是默认租户
+        // (ChangeTenant 只改内存对象重签 token、不持久化)。下面的 UPDATE 又带 tenant_id 过滤,
+        // 故受影响行的业务租户恒等于 tenantId,用它做校验基准即是业务租户基准。
+        await S7FqcInitiatorTenantGuard.EnsureCanOwnFqcAsync(_db, _userManager.UserId, tenantId, "当前用户");
+
         var owner = _userManager.UserId.ToString();
         var n = await _db.Ado.ExecuteCommandAsync(
             $"UPDATE qms_fqcbj SET jyfzr = @Owner WHERE tenant_id = @TenantId AND id IN ({string.Join(",", ids)})",
@@ -173,6 +181,12 @@ public class FqcTaskEntryService : IDynamicApiController, ITransient
         if (input == null || input.InspectorUserId <= 0) throw Oops.Oh("请指定检验负责人");
         var tenantId = ResolveTenantOrThrow();
         await EnsureNotLockedAsync(ids, tenantId); // G4:起流后冻结负责人
+
+        // S7-FQC-INITIATOR-TENANT-GUARD-1:原实现对 InspectorUserId **完全不查用户**,
+        // 只过滤单据租户,于是可以把任意(含其它租户、已停用、甚至不存在的)用户写成负责人。
+        // 仅判 GetById != null 是不够的——必须显式比较 SysUser.TenantId 与单据租户。
+        await S7FqcInitiatorTenantGuard.EnsureCanOwnFqcAsync(_db, input.InspectorUserId, tenantId, "指定检验负责人");
+
         var owner = input.InspectorUserId.ToString();
         var n = await _db.Ado.ExecuteCommandAsync(
             $"UPDATE qms_fqcbj SET jyfzr = @Owner WHERE tenant_id = @TenantId AND id IN ({string.Join(",", ids)})",
@@ -216,6 +230,13 @@ public class FqcTaskEntryService : IDynamicApiController, ITransient
         if (string.IsNullOrWhiteSpace(apply.OwnerId) || !string.Equals(apply.OwnerId!.Trim(), userId, StringComparison.Ordinal))
             throw Oops.Oh("尚未认领或非认领人操作!");
 
+        // S7-FQC-INITIATOR-TENANT-GUARD-1:起流边界的最后一道(本方法末尾 EnsureFlowAsync 会起流,
+        // 而 StartFlow 会把当前用户照实记成 Initiator)。这道防线不能省 —— 库内存在**先于本守卫写入**
+        // 的坏 jyfzr(跨租户用户 Id、以及根本不是 Id 的历史字符串),只在 Assign/Claim 设卡
+        // 挡不住这些存量报检将来被起流而再造一条跨租户 Initiator 实例。
+        // 业务租户取自上面按 tenant_id 查出的业务行,不是 token 直接透传。
+        await S7FqcInitiatorTenantGuard.EnsureBillOwnerTenantSafeAsync(_db, apply.OwnerId, tenantId);
+
         // 复用现有生成逻辑(检规解析 + 12 项快照 + 幂等 UNIQUE),不重复实现
         await _fqcApply.GenerateInspection(new FqcGenerateInput { ApplyId = input.EntryId });
 

+ 98 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/S7FqcInitiatorTenantGuard.cs

@@ -0,0 +1,98 @@
+namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+
+/// <summary>
+/// S7-FQC-INITIATOR-TENANT-GUARD-1:S7 成品检验「检验负责人 / 流程发起人」的租户门禁。
+///
+/// <para><b>要守住的不变量</b>:对任何新进入 S7 FQC 流程的单据,
+/// <c>BusinessTenant == jyfzr 用户.TenantId == ApprovalFlowInstance.Initiator 用户.TenantId</c>。
+/// S7 的契约是三者恒等(<c>FqcInspBillFlowService.SubmitResult</c> 的负责人门与
+/// <c>FqcTaskEntryService.GenerateInspBill</c> 的认领人门共同保证
+/// <c>jyfzr == 当前用户 == N1 Assignee</c>),因此只要 <c>jyfzr</c> 一定同租户,
+/// Initiator 就一定同租户。</para>
+///
+/// <para><b>为什么原来会破</b>(已取证):写入侧与读取侧的租户口径不一致 ——
+/// <list type="bullet">
+/// <item><c>Claim</c> 写 <c>_userManager.UserId</c>,而 <c>UserManager.UserId/TenantId</c> 都取自 JWT claim;
+/// <c>SysTenantService.ChangeTenant</c> 只把**内存中**的 <c>user.TenantId</c> 改成目标租户后重签 token、
+/// 并不持久化,于是超管切租户后 <c>_userManager.TenantId=目标租户</c> 而 <c>SysUser.TenantId=默认租户</c>,
+/// 一个默认租户身份的用户就被写成了目标租户单据的负责人。</item>
+/// <item><c>Assign</c> 写 <c>InspectorUserId</c> 时**完全不查用户**,只过滤单据租户。</item>
+/// <item><c>FlowEngineService:125</c> 起流时 <c>InitiatorId = _userManager.UserId</c>,照实捕获该用户;
+/// 而 <c>ResolveApprovers</c> 的 Initiator 分支用 <c>_userRep.GetByIdAsync</c>(无 ClearFilter)
+/// 按**环境租户**过滤 → 该发起人此后永远解析不出来 → <c>ReturnToPrev</c> 到 N1 时报「未配置审批人」。</item>
+/// </list></para>
+///
+/// <para><b>为什么守在 S7 业务边界而不是引擎</b>:引擎照实记录操作者是正确行为,
+/// 改 <c>FlowEngineService</c> 会把影响面扩散到全部 BizType。
+/// 真正错的是「让一个不属于该租户的人成为该租户单据的负责人」,那是 S7 的业务规则,
+/// 因此三道防线全部落在 S7 自己的入口上(Assign / Claim / 起流边界)。</para>
+///
+/// <para><b>平台权限 ≠ 业务身份</b>:超管有跨租户访问与管理能力,但除非它真实属于该租户,
+/// 就不能成为该租户的 FQC 检验负责人与流程发起人 —— 否则它发起的流程在退回 N1 时无人可解析。</para>
+/// </summary>
+internal static class S7FqcInitiatorTenantGuard
+{
+    /// <summary>
+    /// 校验「<paramref name="userId"/> 可否作为租户 <paramref name="businessTenantId"/> 单据的检验负责人/发起人」。
+    ///
+    /// <para>用 <c>ClearFilter&lt;ITenantIdFilter&gt;()</c> 跨租户查用户是**故意的**:
+    /// 若带租户过滤查,跨租户用户会表现为「查不到」,就无法把「用户不存在」与
+    /// 「用户存在但不属于本租户」区分开,只能给出误导性的文案。</para>
+    ///
+    /// <para>对外文案不暴露 TenantId / RoleId / 内部过滤器细节。</para>
+    /// </summary>
+    /// <param name="db">SqlSugar 客户端</param>
+    /// <param name="userId">待校验用户 Id</param>
+    /// <param name="businessTenantId">单据所属租户(由业务行/业务上下文确定,非登录 token 直接透传)</param>
+    /// <param name="subject">文案主语:如「指定检验负责人」「当前用户」</param>
+    public static async Task EnsureCanOwnFqcAsync(
+        ISqlSugarClient db, long userId, long businessTenantId, string subject)
+    {
+        if (userId <= 0)
+            throw Oops.Oh($"{subject}无效,无法作为检验负责人");
+
+        var user = await db.Queryable<SysUser>()
+            .ClearFilter<ITenantIdFilter>()
+            .Where(u => u.Id == userId)
+            .Select(u => new GuardUser { Id = u.Id, TenantId = u.TenantId, Status = u.Status })
+            .FirstAsync();
+
+        if (user == null)
+            throw Oops.Oh($"{subject}不存在,无法作为检验负责人");
+
+        if (user.Status != StatusEnum.Enable)
+            throw Oops.Oh($"{subject}已停用,无法作为检验负责人");
+
+        if (user.TenantId != businessTenantId)
+            throw Oops.Oh($"{subject}不属于当前单据租户,不能作为该单据的检验负责人");
+    }
+
+    /// <summary>
+    /// 起流前的最后一道:校验业务行上记录的 <c>jyfzr</c> 本身合法且同租户。
+    ///
+    /// <para>这道防线不可省:库内**已存在**先于本守卫写入的坏 <c>jyfzr</c>
+    /// (既有跨租户用户 Id,也有根本不是用户 Id 的字符串)。只在 Assign/Claim 设卡,
+    /// 挡不住这些存量单据将来被起流而再造一条跨租户 Initiator 实例。</para>
+    /// </summary>
+    public static async Task EnsureBillOwnerTenantSafeAsync(
+        ISqlSugarClient db, string? jyfzr, long businessTenantId)
+    {
+        var raw = (jyfzr ?? string.Empty).Trim();
+        if (raw.Length == 0)
+            throw Oops.Oh("该报检尚未指定检验负责人,无法启动检验流程");
+
+        // jyfzr 语义是 SysUser.Id;存量数据里存在非 Id 的历史值,必须显式拒绝而不是当 0 处理。
+        if (!long.TryParse(raw, out var ownerId) || ownerId <= 0)
+            throw Oops.Oh("该报检的检验负责人记录无效,请重新指定负责人后再启动检验流程");
+
+        await EnsureCanOwnFqcAsync(db, ownerId, businessTenantId, "该报检的检验负责人");
+    }
+
+    /// <summary>只取校验所需三列,避免整实体投影把无关列带进来。</summary>
+    private sealed class GuardUser
+    {
+        public long Id { get; set; }
+        public long? TenantId { get; set; }
+        public StatusEnum Status { get; set; }
+    }
+}