Kaynağa Gözat

fix(flow): block generic approval bypass for S7 FQC | server 1.0.545

把真实 BizType S7_FQC_INSPBILL 纳入既有 DedicatedEntryOnlyBizTypes 精确守卫,
使审批中心的通用 approve / reject / batchApprove / batchReject 在任何 Flow mutation
之前 fail-closed。未新造第二套守卫、未改 FlowEngine、未动 S7 业务代码与 UI。

为什么 S7 的绕过面是全部业务语义:本链的 FqcInspBillFlowBizHandler 三个回调
(OnFlowStarted / OnNodeCompleted / OnFlowCompleted)全是 no-op,业务写入一律由
FqcInspBillFlowService 在引擎之外、同事务内完成。于是通用审批会:
- N1 approve:submit-result 从未写 pd/hgsl/bhgsl/clfs 就推到 N2;且 GW_RESULT 按 pd 算
  disposition_required,pd 为 null 时算 0 → 不合格单静默绕过 N3 QE 处置直接结束;
- N2 approve:跳过 TryEnqueueFqcPassOutboxAsync,合格出站 Outbox 永不入队;
- N2 reject:FlowEngine.Reject 把实例置 Rejected 终态并取消所有待办,而专用
  supervisor-reject 是 ReturnToPrev 回 N1 + 同事务复位 qms_fqcbj.FINSPECTSTATUS
  + 同步 mdp_std_fqc_task,且通用 Reject 连退回意见都不要求;
- N3 approve:推到 end 而 ado_s7_fqc_qe_disposition 永不写入,处置轨迹为空无法补记。

BizType 三来源一致:FqcInspBillFlowConst.BizType / ApprovalFlow 已发布定义 /
14 条 ApprovalFlowInstance。

Runtime(先确认含新守卫的 dll 于 20:59:36 写入、进程 21:00:40 启动且 /proc/maps
证实加载该 dll,再发 probe):
- 真实待办人 AIDOPDemo(租户 797403760988229)对自己的 N2/N3/N1 待办发起
  approve / reject / batchApprove / batchReject → 全部拦截(单项 code=400 守卫文案;
  批量 successCount=0 / failCount=1 且 failItems 带同一文案);
- 零 mutation:Instance / Task / Log / CompletedNode / QE 处置 / S5+S6 Task 六项
  MD5 签名前后逐一相同,qms_qcpp_inspbill 的 pd/hgsl/bhgsl/clfs/FBILLSTATUS 未变;
- 守卫早于引擎归属校验:非待办人得到的是守卫文案而非「非审批人」;
- 未误伤通用审批:CONTRACT_REVIEW 返回引擎的「当前用户不是该任务的审批人」,非守卫文案;
- S6 回归:S6_PROCESS_INSPECTION 本人 N2 待办仍被拦截;
- 专用链仍可用:同一张单跑通 N2 退回(→N1,FINSPECTSTATUS 复位「检验中」、
  pd/hgsl/bhgsl 保留)→ N1 提交(→N2)→ 主管通过(→end),另一张单跑通 N3 QE 处置
  (写入 ado_s7_fqc_qe_disposition 且不覆盖检验结果)。

本批不处理 S7 租户授权、legacy numeric RoleId、历史实例快照与 S7 UI(见回执登记)。
YY968XX 1 gün önce
ebeveyn
işleme
27ff396022

+ 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.544</AssemblyVersion>
-    <FileVersion>1.0.544</FileVersion>
-    <Version>1.0.544</Version>
+    <AssemblyVersion>1.0.545</AssemblyVersion>
+    <FileVersion>1.0.545</FileVersion>
+    <Version>1.0.545</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 2 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/IpqcInspectionGenericApprovalGuardContractTests.cs

@@ -118,7 +118,8 @@ public class IpqcInspectionGenericApprovalGuardContractTests
     /// <summary>不误伤:名单是精确字典查找,未把通用审批业务(如 CONTRACT_REVIEW)纳入。</summary>
     [Theory]
     [InlineData("CONTRACT_REVIEW")]
-    [InlineData("S7_FQC_INSPBILL")]
+    // 2026-09-14 移除 S7_FQC_INSPBILL:当时 S7 被列为 OUT OF SCOPE 故断言"未纳入",
+    // 现已由 S7-FQC-GUARD-1 正式纳入守卫(见 S7FqcGenericApprovalGuardContractTests)。
     [InlineData("SMART_OPS_IMPROVEMENT")]
     [InlineData("EXCEPTION_REPORT")]
     public void Guard_DoesNotBlockNonDedicatedBizTypes(string bizType)

+ 264 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S7FqcGenericApprovalGuardContractTests.cs

@@ -0,0 +1,264 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// S7 成品检验单(<c>S7_FQC_INSPBILL</c>,源 qms_qcpp_inspbill)通用审批旁路封堵 · 源码契约测试(S7-FQC-GUARD-1)。
+///
+/// 背景:守卫名单此前只有 S5_IQC_INSPBILL / S6_PROCESS_INSPECTION / IPQC_INSPECTION,
+/// S7 一直在名单外(早前批次显式记为 OUT OF SCOPE)。
+///
+/// 为什么 S7 的绕过面是**全部**业务语义:本链的 <see cref="FqcInspBillFlowBizHandler"/> 三个回调
+/// (OnFlowStarted / OnNodeCompleted / OnFlowCompleted)**全是 no-op**,
+/// 业务写入一律由 <c>FqcInspBillFlowService</c> 在引擎之外、同事务内完成。于是通用审批:
+///   ① N1 approve → submit-result 从未写 pd/hgsl/bhgsl/clfs 就推到 N2;更严重的是 GW_RESULT
+///      网关按 pd 算 disposition_required,pd 为 null 时算 0 → 不合格单静默绕过 N3 QE 处置直接结束;
+///   ② N2 approve → 跳过 TryEnqueueFqcPassOutboxAsync,合格出站 Outbox 永不入队;
+///   ③ N2 reject → FlowEngine.Reject 把实例置 Rejected 终态并取消所有待办,而专用 supervisor-reject
+///      是 ReturnToPrev 回 N1 + 同事务复位 qms_fqcbj.FINSPECTSTATUS + 同步 mdp_std_fqc_task;
+///   ④ N3 approve → 推到 end 而 ado_s7_fqc_qe_disposition 永不写入,处置轨迹为空且无法补记。
+///
+/// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowTaskService 依赖
+/// SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化。真实阻断由本批 Runtime R1–R4 覆盖。
+/// </summary>
+public class S7FqcGenericApprovalGuardContractTests
+{
+    private const string BizType = "S7_FQC_INSPBILL";
+    private const string Guard = "EnsureDedicatedEntryOnlyAsync";
+
+    private static string FlowTaskService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
+
+    private static string FlowConst() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowBizHandler.cs"));
+
+    private static string FlowService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowService.cs"));
+
+    private static string Dict() => Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
+
+    // ── 纳入守卫 ──
+
+    /// <summary>S7 真实 BizType 必须在名单里,且与常量定义一致(不允许两处写不同字面量)。</summary>
+    [Fact]
+    public void S7BizType_IsInDedicatedEntryGuard()
+    {
+        Assert.Contains($"[\"{BizType}\"]", Dict());
+        Assert.Contains($"public const string BizType = \"{BizType}\"", FlowConst());
+    }
+
+    /// <summary>§15 Regression Freeze:新增 S7 不得挤掉既有三条。</summary>
+    [Theory]
+    [InlineData("S5_IQC_INSPBILL")]
+    [InlineData("S6_PROCESS_INSPECTION")]
+    [InlineData("IPQC_INSPECTION")]
+    public void ExistingGuardedBizTypes_ArePreserved(string bizType)
+    {
+        Assert.Contains($"[\"{bizType}\"]", Dict());
+    }
+
+    /// <summary>名单恰好四条 —— 防止本批顺手扩大守卫范围。</summary>
+    [Fact]
+    public void Guard_CoversExactlyFourBizTypes()
+    {
+        Assert.Equal(4, CountOf(Dict(), "\"] = \""));
+    }
+
+    // ── 四个通用入口全覆盖(§14)──
+
+    /// <summary>approve / reject / batchApprove / batchReject 四个入口都必须先过同一守卫。</summary>
+    [Theory]
+    [InlineData("_engine.Approve(input.TaskId, input.Comment)")]
+    [InlineData("_engine.Reject(input.TaskId, input.Comment)")]
+    public void SingleActions_GuardPrecedesEngineCall(string engineCall)
+    {
+        var s = FlowTaskService();
+        var at = s.IndexOf(engineCall, StringComparison.Ordinal);
+        Assert.True(at > 0, $"未找到引擎调用点:{engineCall}");
+        Assert.True(s.LastIndexOf(Guard + "(", at, StringComparison.Ordinal) > 0,
+            $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫");
+    }
+
+    /// <summary>
+    /// 批量入口的循环体各自持有守卫。批量端点不会走单项方法体,
+    /// 少一处就等于批量仍可绕过(§21 / §22)。
+    /// </summary>
+    [Fact]
+    public void BatchActions_EachLoopBodyCallsGuard()
+    {
+        var s = FlowTaskService();
+        Assert.Equal(4, CountOf(s, Guard + "(taskId)") + CountOf(s, Guard + "(input.TaskId)"));
+        foreach (var batch in new[] { "BatchApprove", "BatchReject" })
+        {
+            var body = Slice(s, $"public async Task<BatchActionResult> {batch}(", "return result;");
+            Assert.Contains(Guard + "(taskId)", body);
+        }
+    }
+
+    // ── §11 Guard 必须早于任何 mutation ──
+
+    /// <summary>守卫是动作方法的第一条语句,因此 fail 时任务状态/实例/日志/下一节点都还没动。</summary>
+    [Theory]
+    [InlineData("public async Task Approve(TaskActionInput input)")]
+    [InlineData("public async Task Reject(TaskActionInput input)")]
+    public void Guard_IsFirstStatementOfAction(string signature)
+    {
+        var body = Slice(FlowTaskService(), signature, "}");
+        var lines = body.Split('\n').Select(l => l.Trim()).Where(l => l != "" && l != "{" && !l.StartsWith("//", StringComparison.Ordinal)).ToList();
+        var first = lines.FirstOrDefault(l => !l.StartsWith("public ", StringComparison.Ordinal));
+        Assert.Equal($"await {Guard}(input.TaskId);", first);
+    }
+
+    /// <summary>守卫自身只读不写:不得在判定过程里改任何实体。</summary>
+    [Fact]
+    public void Guard_PerformsNoMutation()
+    {
+        var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n    /// <summary>");
+        foreach (var write in new[] { "AsUpdateable", "InsertAsync", "DeleteAsync", "ExecuteCommandAsync", "UseTranAsync" })
+            Assert.DoesNotContain(write, guard);
+    }
+
+    // ── §10 精确匹配 ──
+
+    /// <summary>必须是精确字典查表,禁止 Contains / StartsWith 之类模糊匹配。</summary>
+    [Fact]
+    public void Guard_UsesExactDictionaryLookup()
+    {
+        var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n    /// <summary>");
+        Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", guard);
+        foreach (var fuzzy in new[] { ".Contains(", ".StartsWith(", ".EndsWith(", "ToUpper", "ToLower" })
+            Assert.DoesNotContain(fuzzy, guard);
+    }
+
+    /// <summary>§16 Control:明确允许通用审批的 BizType 不得被纳入。</summary>
+    [Theory]
+    [InlineData("CONTRACT_REVIEW")]
+    [InlineData("ORDER_REVIEW")]
+    [InlineData("SMART_OPS_IMPROVEMENT")]
+    [InlineData("EXCEPTION_REPORT")]
+    [InlineData("MATERIAL_SHORTAGE")]
+    public void Guard_DoesNotBlockGenericApprovalBizTypes(string bizType)
+    {
+        Assert.DoesNotContain(bizType, Dict());
+    }
+
+    /// <summary>§9 禁止散落 if:不得在四个动作里各写一遍 S7 的判断。</summary>
+    [Fact]
+    public void NoScatteredPerBizTypeIfStatements()
+    {
+        var s = FlowTaskService();
+        // BizType 字面量只应出现在名单里(1 次),不应出现在任何 if 条件中
+        Assert.Equal(1, CountOf(s, $"\"{BizType}\""));
+        Assert.DoesNotContain($"== \"{BizType}\"", s);
+    }
+
+    // ── §12 权威来源与不泄漏 ──
+
+    /// <summary>权威 BizType 必须从库中 instance 反查,不得取自请求体(否则可伪造绕过)。</summary>
+    [Fact]
+    public void Guard_ReadsBizTypeFromDatabase()
+    {
+        var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n    /// <summary>");
+        Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
+        Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
+        Assert.DoesNotContain("input.BizType", guard);
+    }
+
+    /// <summary>§13 对外文案不得泄漏 RoleId / TenantId / 流程定义 Id 等内部实现。</summary>
+    [Fact]
+    public void GuardMessage_DoesNotLeakInternals()
+    {
+        var dict = Dict();
+        foreach (var leak in new[] { "TenantId", "RoleId", "FlowId", "DefinitionId", "InstanceId" })
+            Assert.DoesNotContain($"{leak}=", dict);
+    }
+
+    /// <summary>文案必须指向专用业务页,且不是裸技术异常。</summary>
+    [Fact]
+    public void GuardMessage_PointsToDedicatedPage()
+    {
+        var line = Dict().Split('\n').Single(l => l.Contains($"[\"{BizType}\"]", StringComparison.Ordinal));
+        Assert.Contains("FQC检验单详情", line);
+        Assert.Contains("不支持在审批中心直接同意/拒绝", line);
+        foreach (var bad in new[] { "BizType not allowed", "NullReference", "500" })
+            Assert.DoesNotContain(bad, line);
+    }
+
+    // ── 封堵的前提:专用入口必须仍然存在且直调引擎 ──
+
+    /// <summary>四个专用入口都必须在(否则封堵等于封死业务)。</summary>
+    [Theory]
+    [InlineData("submit-result")]
+    [InlineData("supervisor-approve")]
+    [InlineData("supervisor-reject")]
+    [InlineData("qe-submit-disposition")]
+    public void DedicatedEntries_StillExist(string route)
+    {
+        Assert.Contains($"[HttpPost(\"{route}\")]", FlowService());
+    }
+
+    /// <summary>专用入口直调 FlowEngine,不经 FlowTaskService —— 故守卫不会误伤正常业务路径。</summary>
+    [Fact]
+    public void DedicatedEntries_CallEngineDirectly_NotViaFlowTaskService()
+    {
+        var s = FlowService();
+        Assert.Contains("_flowEngine.Approve(", s);
+        Assert.Contains("_flowEngine.ReturnToPrev(", s);
+        Assert.DoesNotContain("FlowTaskService", s);
+        Assert.DoesNotContain("flowTask/approve", s);
+    }
+
+    /// <summary>
+    /// 冻结"为什么必须封堵"的两条根据:
+    /// ① Handler 回调全 no-op(业务副作用不在引擎里);② 专用 reject 走 ReturnToPrev 而非置终态。
+    /// 任一条将来被改动,本守卫的理由需要重新评估,测试在此提醒。
+    /// </summary>
+    [Fact]
+    public void BypassRationale_HandlerCallbacksAreNoOp()
+    {
+        var h = FlowConst();
+        foreach (var cb in new[] { "OnFlowStarted", "OnNodeCompleted", "OnFlowCompleted" })
+        {
+            var line = h.Split('\n').Single(l => l.Contains($"public Task {cb}(", StringComparison.Ordinal));
+            Assert.Contains("Task.CompletedTask", line);
+        }
+    }
+
+    /// <summary>专用 supervisor-reject 的业务副作用必须仍在(它们正是通用 Reject 绕过的东西)。</summary>
+    [Fact]
+    public void BypassRationale_DedicatedRejectHasBusinessSideEffects()
+    {
+        var body = Slice(FlowService(), "public async Task<FqcFlowStateOutput> SupervisorReject(", "public async Task<FqcFlowStateOutput> QeSubmitDisposition(");
+        Assert.Contains("退回必须填写意见", body);
+        Assert.Contains("_flowEngine.ReturnToPrev(", body);
+        Assert.Contains("UPDATE qms_fqcbj SET FINSPECTSTATUS", body);
+        Assert.Contains("SyncBillToStdSafeAsync", body);
+    }
+
+    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));
+    }
+}

+ 13 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowTask/FlowTaskService.cs

@@ -187,6 +187,19 @@ public class FlowTaskService : IDynamicApiController, ITransient
         // 另:专用 supervisor-return 要求退回意见必填、经 ReturnToPrev 回到 N1,通用 Reject 直接置实例终态。
         // 2026-09-14 实测:通用 approve 曾真实把一条 IPQC N2 待办推进到 end。
         ["IPQC_INSPECTION"] = "过程检验单请在「过程检验」业务页完成检验、主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
+        // S7 成品检验单:本链的 IFlowBizHandler(FqcInspBillFlowBizHandler)三个回调**全是 no-op**,
+        // 业务副作用一律由专用 API 在引擎之外、同事务内完成,故通用审批绕过的是全部业务语义:
+        // ① N1 通用 approve 会在 submit-result 从未写入 pd/hgsl/bhgsl/clfs 的情况下推到 N2 ——
+        //    主管收到一张没有检验结果的单;更严重的是 GW_RESULT 网关读 pd 判 disposition_required,
+        //    pd 为 null 时算 0,不合格单会被静默绕过 N3 QE 处置直接结束。
+        // ② N2 通用 approve 跳过 TryEnqueueFqcPassOutboxAsync,合格出站 Outbox 永不入队。
+        // ③ N2 通用 reject 走 FlowEngine.Reject——实例直接置 Rejected 终态并取消所有待办,
+        //    而专用 supervisor-reject 是 ReturnToPrev 回 N1 + 同事务把 qms_fqcbj.FINSPECTSTATUS
+        //    复位「检验中」+ 同步 mdp_std_fqc_task.inspect_progress;走通用则单据永久卡死、
+        //    检验进度永远停在「检验完成」,且通用 Reject 连退回意见都不要求。
+        // ④ N3 通用 approve 推到 end 而 ado_s7_fqc_qe_disposition 永不写入 —— 流程显示闭环、
+        //    处置轨迹为空且事后无法补记(与 IPQC_INSPECTION 同款,该形态 2026-09-14 已实证发生过)。
+        ["S7_FQC_INSPBILL"] = "成品检验单请在「FQC检验单详情」页完成检验结果提交、主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
     };
 
     /// <summary>