# .NET工作流引擎 **Repository Path**: ruovea-tools/RuoVea.Workflow ## Basic Information - **Project Name**: .NET工作流引擎 - **Description**: 轻量级设计 核心引擎体积小巧,易于集成到现有项目中 多格式支持 支持Fluent+API、JSON和YAML三种工作流定义方式 插件化架构 支持自定义触发器和活动,可按需扩展功能 持久化支持 内置多种数据库适配器,确保流程状态安全存储 事件驱动 完善的事件订阅和发布机制,便于系统集成 扩展功能 审批节点 Approval(AND/OR/NONE 多人)、抄送 CC、同意/拒绝/转交/退回 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-09-03 - **Last Updated**: 2026-10-03 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 🔗 [.NET工作流引擎](https://gitee.com/ruovea-tools/RuoVea.Workflow) [![NuGet](https://img.shields.io/nuget/v/RuoVea.Workflow.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/RuoVea.Workflow) [![NuGet Downloads](https://img.shields.io/nuget/dt/RuoVea.Workflow.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/RuoVea.Workflow) [![Target Framework](https://img.shields.io/badge/.NET-netstandard2.0%20%7C%208.0-512BD4?style=flat-square&logo=.net)](https://dotnet.microsoft.com/) [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) > **携带审批语义的工作流引擎** — 以官方 [Workflow-Core](https://github.com/danielgerlag/workflow-core)(Daniel Gerlag,MIT)为完整、通用、生产就绪的底座,叠加面向业务流程的**审批专用语义**(审批 / 会签 / 抄送 / 加签 / 转交 / 退回 / 终止)。 --- ## 📖 目录 - [📋 概览](#-概览) - [✨ 特性亮点](#-特性亮点) - [📦 安装](#-安装) - [⚡ 30 秒快速开始](#-30-秒快速开始) - [📸 效果预览](#-效果预览) - [🧪 示例项目](#-示例项目) - [Demo 业务示例(入库单 × 工作流)](#demo-业务示例入库单--工作流) - [🧩 核心能力](#-核心能力) - [审批节点(AND / OR / NONE 会签)](#审批节点and--or--none-会签) - [审批动作(同意 / 拒绝 / 转交 / 退回 / 加签)](#审批动作同意--拒绝--转交--退回--加签) - [抄送(CC)](#抄送cc) - [业务接口(IUserProvider / IApprovalExecutor / INotification)](#业务接口iuserprovider--iapprovalexecutor--inotification) - [🔌 Fluent 建模](#-fluent-建模) - [🧭 组件使用说明](#-组件使用说明) - [服务注册](#服务注册) - [节点类型(type)对照表](#节点类型type对照表) - [扩展点一览](#扩展点一览) - [审批节点「表单权限」:字段级只读 / 编辑 / 隐藏](#审批节点表单权限字段级只读--编辑--隐藏) - [流程表单字段的展示名与节点审批意见](#流程表单字段的展示名formfieldschemas与节点审批意见) - [审批事件机制](#审批事件机制) - [宿主端点调用约定(参数绑定的坑)](#宿主端点调用约定参数绑定的坑) - [🏢 业务场景接入说明](#-业务场景接入说明) - [场景一:业务单据 × 审批(业务表为唯一事实来源)](#场景一业务单据--审批业务表为唯一事实来源) — 含 **type=90 回调接入 6 步 + 自检清单 + 运行时序** - [场景二:条件分支(发起时求值)](#场景二条件分支发起时求值) - [场景三:主干拒绝 vs 分支内拒绝](#场景三主干拒绝-vs-分支内拒绝) - [场景四:事务边界与失败补偿](#场景四事务边界与失败补偿) - [场景五:审批人来源解析](#场景五审批人来源解析) - [场景六:超时 / 提醒 / 延迟](#场景六超时--提醒--延迟) - [场景七:抄送与触发器](#场景七抄送与触发器) - [场景八:前端接线](#场景八前端接线) - [⚠️ 已知限制与注意事项](#️-已知限制与注意事项) - [🛡️ 设计理念](#️-设计理念) - [🗺️ 许可证](#️-许可证) --- ## 📋 概览 **RuoVea.Workflow** 不做重复造轮子——引擎核心、DSL、Users、Persistence 全部沿用官方 Workflow-Core 实现,保证与官方生态完全兼容。本库的独特价值在于**补齐官方缺失的审批业务语义**。 ``` ┌─────────────────────────────────────────────────────┐ │ RuoVea.Workflow │ ├─────────────────────────────────────────────────────┤ │ │ │ Workflow-Core (官方底座, MIT) │ │ ├─ 引擎核心 / Fluent Builder / DSL / Users │ │ ├─ 事件驱动 + 队列 + 分布式锁 + 多持久化 provider │ │ └─ If/While/Foreach/Saga/Decide/When/Schedule... │ │ │ │ RuoVea 审批语义 (本库独有) │ │ ├─ Approval 审批节点 (AND/OR/NONE 会签状态机) │ │ ├─ CC 抄送节点 │ │ ├─ IUserProvider / IApprovalExecutor / INotification│ │ └─ 同意 / 拒绝 / 转交 / 退回 / 前加签 / 后加签 / 终止 │ │ │ └─────────────────────────────────────────────────────┘ ``` --- ## 📦 安装 ```bash dotnet add package RuoVea.Workflow ``` > 目标框架:`netstandard2.0`(可被 `net8.0` / `net10.0` 项目引用)。 --- ## ✨ 特性亮点 | 特性 | 说明 | | --- | --- | | **官方兼容** | 直接引用官方 `WorkflowCore` 包,引擎核心 / DSL / Users / Persistence 完全对齐官方生态 | | **轻量级设计** | 核心引擎体积小巧,易于集成到现有项目中 | | **多格式支持** | 支持 `Fluent + API`、`JSON` 和 `YAML` 三种工作流定义方式 | | **插件化架构** | 支持自定义触发器和活动,可按需扩展功能 | | **持久化支持** | 内置多种数据库适配器(内存 / SqlServer / PG / MySQL / Sqlite / MongoDB / RavenDB 等),确保流程状态安全存储 | | **事件驱动** | 完善的事件订阅和发布机制,便于与系统集成 | | **控制流原语** | 完整支持分支 `If` / `When` / `Decide` / `OutcomeSwitch`、循环 `While` / `Foreach`、定时 `Delay` / `Recur` / `Schedule`、并行 `Sequence` / `Saga` / `Parallel`、子流程 `SubWorkflow` 等 | | **等待原语** | `WaitFor` 事件等待、`Activity` 外部活动、`Schedule` 定时绑定等,覆盖复杂业务编排 | | **会签审批** | 审批节点 `Approval` 内置 `AND`(全部)/ `OR`(任一)/ `NONE`(顺序)三种会签模型 | | **丰富审批动作** | 同意 / 拒绝 / 转交(`RedirectTask`)/ 退回(`RedirectProcess`)/ 前加签 / 后加签 / 终止(`TerminateWorkflow`) | | **抄送通知** | `CC` 抄送节点,经 `INotification` 扩展点广播审批结果与抄送记录 | | **审批状态管理** | 会签状态持久化于 `ExecutionPointer.PersistenceData`,进程重启后可恢复等待 | | **业务可注入** | `IUserProvider` / `IApprovalExecutor` / `INotification` 均注册默认实现,可用 DI 随时替换 | | **多框架适用** | 目标 `netstandard2.0`,可被 `net8.0` / `net10.0` 项目无缝引用 | --- ## ⚡ 30 秒快速开始 ```csharp var services = new ServiceCollection(); services.AddLogging(b => b.AddConsole()); services.AddWorkflow(); // 官方引擎底座 services.AddRuoVeaApproval(); // 注册审批接口默认实现 var sp = services.BuildServiceProvider(); var host = sp.GetRequiredService(); await host.StartAsync(default); ``` --- ## 📸 效果预览 以下截图取自仓库 Demo `RuoVea.Workflow.Demo`(后端 + Vue3 前端完整系统,`superAdmin / 123456` 登录)。截图由 `RuoVea.Workflow.Demo/Web/capture-screenshots.mjs`(Playwright headless)一次性采集,供直观了解组件在真实业务系统中的落地效果。
流程列表
流程列表 — 流程定义管理,含启停用 / 设计 / 发起
流程设计器
流程设计器 — 链式节点编排(审批 / 条件路由 / 抄送 / 回调节点)
发起审批
发起审批 — 选择流程定义、填单发起,表单字段即条件分支求值依据
流程实例
流程实例 — 全量实例与状态筛选,可下钻查看流转树
我的申请
我的申请 — 我发起的所有流程,含表单字段中文标签与节点审批意见
流程审批
流程审批 — 审批人视角:审批进度 + 业务单据入口(查看入库单),支持同意 / 拒绝
挂起待办
挂起待办 — 引擎侧运行时队列(超时 / 提醒 / 延迟),排查"流程为什么一直挂着"
入库单列表
业务单据列表 — 业务表是唯一事实来源,状态由引擎回写驱动
> 组件的功能清单与接口用法见组件包内自述 [`RuoVea.Workflow/README.md`](./RuoVea.Workflow/README.md)(该文件即 NuGet 包页面展示的内容)。 --- ## 🧪 示例项目 仓库内置可直接运行、用于功能测试的示例: | 项目 | 类型 | 用途 | | --- | --- | --- | | `RuoVea.Workflow.Demo` | ASP.NET Core Web API + Vue3 SPA(前后端完整系统) | **业务接入样板**:系统管理 + 工作流 + 入库单业务模块,演示「业务单据 ↔ 工作流 ↔ 业务表」全链路 | > `RuoVea.Workflow.Demo` 原先还带有 `Web.Mvc`(MVC 版演示)、`WebApi`(REST 版演示)、`Web.UI`(独立画布 SPA)三个示例项目,**已随 Demo 重构移除**(其能力被 Demo 的前后端完整系统覆盖,画布接口 `/api/Canvas` 亦不再保留)。若你从旧版本升级,相关路径与接口需按上面的 Demo 章节重新对表。 ### Demo 业务示例(入库单 × 工作流) `RuoVea.Workflow.Demo` 是**完整业务接入样板**:把「业务单据 → 提交审批 → 引擎驱动 → 回写业务表」整条链路跑通,包含后端(`RuoVea.Workflow.Entry`,端口 **5000**)、前端(`Web`,vite dev)与主子表业务页面,可作为新业务模块的可复制模板。 | 项目 | 类型 | 用途 | | --- | --- | --- | | `RuoVea.Workflow.Demo/RuoVea.Workflow.Entry` | ASP.NET Core Web API + 前端宿主 | 完整后台:系统管理 + 工作流 + 入库单业务模块,含菜单/流程定义种子 | | `RuoVea.Workflow.Demo/Web` | Vue3 + Vite + TS + Element Plus | 业务页面(入库单列表 + 主子表编辑弹窗)与工作流待审/流向页 | 启动方式: ```bash # 后端(端口 5000,菜单与流程定义种子自动增量下发) dotnet run --project RuoVea.Workflow.Demo/RuoVea.Workflow.Entry # 前端 cd RuoVea.Workflow.Demo/Web && npm run dev ``` **入库单审批操作路径**(核心验证:业务表状态由引擎推进驱动): 1. 进入「入库管理 / 入库单」→ 新增单据(单头 + 明细行)→ 保存(状态「草稿」); 2. 点「提交审批」→ 状态变「审批中」,`instanceId` 回填(后端 `InboundOrderService.SubmitAsync`:校验草稿 → 置审批中 → 事务外启动流程并携带 `BusinessId`/`BusinessType` → 回填实例 Id,失败回滚); 3. 审批人同意 / 拒绝(走既有「待审核」页); 4. 刷新列表:**同意 → 状态「已入库」;拒绝 → 状态「已驳回」**(主干或分支内的拒绝都由业务回调节点 type=90 调用宿主 `IBusinessApprovalCallback` 回写)。 > 条件分支按发起时 `FormFields.amount` 求值:金额 > 1000 走财务会签,否则走默认分支直达回调。分支内审批拒绝**同样会回写**(分支子链末尾出口是通配 outcome),详见「场景三」。 **可运行示例文件**(`examples/`,链式 `FlowDefinition` 管线;旧的画布管线已废弃,见 [`examples/README.md`](./examples/README.md)): | 文件 | 说明 | | --- | --- | | [`examples/demo-inbound-order.json`](./examples/demo-inbound-order.json) | 入库单审批链式定义(条件路由 + 业务回调节点 + 主干拒绝出口) | | [`examples/flowlong-demo-inbound.md`](./examples/flowlong-demo-inbound.md) | 链路结构、回写约定、场景边界与运行说明 | | [`examples/verify-inbound-order.mjs`](./examples/verify-inbound-order.mjs) | 一键验证脚本(小额默认分支 / 大额财务会签两条链路) | | [`examples/README.md`](./examples/README.md) | 全部示例清单(画布 5 例 + 链式 4 例)与使用方式 | > 完整接入细节(事务边界、失败补偿、审批人来源、前端接线、已知限制)见下方「🏢 业务场景接入说明」章节。 --- ### 审批节点(AND / OR / NONE 会签) 内置三种会签类型: - **AND(全部)**:需所有审批人均同意才放行; - **OR(任一)**:任意一人同意即放行; - **NONE(顺序)**:按顺序依次审批,全部同意才放行。 进程内基于官方事件驱动机制实现(`WaitForEvent` + 重新挂起),会签状态存放于 `ExecutionPointer.PersistenceData`,天然兼容官方事件消费与持久化。 ### 审批动作(同意 / 拒绝 / 转交 / 退回 / 加签) `IApprovalExecutor` 提供完整动作:`Agree` / `Refuse` / `RedirectTask`(转交)/ `RedirectProcess`(退回)/ `AppendTaskBefore`(前加签)/ `AppendTaskAfter`(后加签)/ `TerminateWorkflow`(终止)。 ### 抄送(CC) `CC` 节点负责抄送记录,通过 `INotification` 扩展点广播。 ### 业务接口(IUserProvider / IApprovalExecutor / INotification) - `IUserProvider`:`UserID` / `DeptID`(供审批解析当前用户)。 - `IApprovalExecutor`:审批执行接口(包装官方 `IWorkflowHost.PublishEvent`)。 - `INotification`:审批结果 / 抄送通知抽象。 三个接口均通过 `AddRuoVeaApproval()` 注册默认实现(`DefaultUserProvider` / `ApprovalExecutor` / `DefaultNotification`),可被 DI 替换。 --- ## 🔌 Fluent 建模 ```csharp builder .StartWith() .Approval(d => d.Approvers, RuoVea.Workflow.Primitives.Approve.Approval.TypeAnd) .EndWorkflow(); ``` `Approval(...)` / `CC(...)` 为 Fluent 扩展方法,审批人列表与会签类型一行配置。 --- ## 🧭 组件使用说明 ### 服务注册 ```csharp using RuoVea.Workflow; // AddRuoVeaApproval / AddRuoVeaApprovalScanners using RuoVea.Workflow.Interface; var builder = WebApplication.CreateBuilder(args); // 1. 官方引擎底座:必须自带一个持久化 provider builder.Services.AddWorkflow(x => x.UseSqlite(connectionString, true)); // 2. 审批语义默认实现(IUserProvider / INotification / IApprovalExecutor + 三个内存版登记处)— 放在覆盖行之前 builder.Services.AddRuoVeaApproval(); // 3. 覆盖为宿主实现(可选,但生产环境通常必填,见下方"扩展点一览") builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // 4. 后台扫描器(超时自动推进 / 到点提醒 / 延迟放行)— 依赖第 2 步的三个登记处,必须在其后调用 builder.Services.AddRuoVeaApprovalScanners(); // 5. 【生产建议】把登记处换成持久化版:登记项落库,进程重启后未到期的超时/提醒/延迟不再丢失。 // 需要先注册 IPendingWorkStore 实现(可直接继承 JsonPendingWorkStore,只需实现「写一行 + 原子取走」)。 builder.Services.AddSingleton(); builder.Services.AddRuoVeaPersistentRegistries(); ``` > **注册顺序**:`AddRuoVeaApproval()` 内部全部使用 `TryAdd*`,**先注册者生效**。因此自定义实现必须在它**之前**注册,或在它之后用 `AddSingleton` 覆盖(`AddSingleton` 会追加注册并使最后一个生效)。`AddRuoVeaPersistentRegistries()` 用的是 `Replace`,与顺序无关,但必须在 `AddRuoVeaApproval()` 之后调用。 > > **扫描器可选**:不需要「审批超时自动推进 / 到点提醒 / 延迟节点」这三项能力时,可以不调用 `AddRuoVeaApprovalScanners()`,其余功能不受影响。 > > **登记处持久化可选**:不调用第 5 步时沿用内存版(单进程演示够用)。生产环境建议至少替换延迟登记处——内存版下重启会让未到期的延迟节点**永久挂起**。 ### 节点类型(type)对照表 链式流程定义的 `FlowNode.Type` 决定引擎步骤的构造(见 `FlowlongDefinitionService.CreateStep`): | type | 名称 | 引擎步骤 | 说明 | | --- | --- | --- | --- | | `0` | 发起人 / 开始 | `WorkflowStep` | 流程入口,`default` 分支亦落到此 | | `1` | 审批 | `ApprovalStep` | 由 `ExamineMode` 决定顺序 / 会签 / 或签 / 票签 | | `2` | 抄送 | `CCStep` | 抄送人取自 `NodeAssigneeList` | | `3` | 条件(分支自身) | — | 条件路由的子节点,承载 `ConditionList`,不单独成像步骤 | | `4` | 条件路由 | `ConditionalBranchStep` | 子节点为若干 type=3 分支;**末位且无条件者**为默认分支 | | `30` | 自动通过 | `AutoNodeStep(Accept=true)` | 结果写入 `IAutoResultWriter.AutoResult` | | `31` | 自动拒绝 | `AutoNodeStep(Accept=false)` | 同上 | | `50` | 延迟等待 | `DelayStep` | `delayValue × delayUnit` 折算秒数,由 `DelayScanner` 到点放行 | | `51` | 触发器 | `TriggerStep` | 回调宿主 `ITriggerHandler.HandleAsync`,可动态改写后续审批人 | | `52` | 并行分支 | `ParallelStep` | 子分支并行展开后汇合 | | `53` | 包容分支 | `InclusiveStep` | 命中多条分支时全部展开 | | `90` | **业务回调** | `BusinessCallbackNodeStep` | 审批汇合后回调宿主 `IBusinessApprovalCallback` 回写业务表 | | `-1` | 结束 | `EndStep` | 流程终点 | | `20` / `54` | 子流程 / 路由分支 | `AutoNodeStep(Accept=true)` | **基础引擎暂未实现**,兜底为自动通过以免阻塞(记 Warning 日志) | > 新增 type 时必须同步修改两处:`CreateStep` 的 `switch`(否则落到 `default` 变成"开始"节点)与 `DefaultName`(节点未命名时的兜底显示名)。 ### 扩展点一览 引擎只定义契约,业务语义由宿主注入。"未注册时的行为"一列决定了接入是否**必须**: | 扩展点 | 命名空间 | 职责 | `AddRuoVeaApproval()` 是否提供默认实现 | 未注册时的行为 | | --- | --- | --- | --- | --- | | `IUserProvider` | `RuoVea.Workflow` | 提供当前用户 `UserID` / `DeptID` | ✅ `DefaultUserProvider` | 使用默认(空用户),审批人解析可能不可用 | | `INotification` | `RuoVea.Workflow` | 审批结果 / 抄送通知广播 | ✅ `DefaultNotification` | 通知丢弃,不报错 | | `IApprovalExecutor` | `RuoVea.Workflow` | 审批动作门面(包装 `IWorkflowHost.PublishEvent`) | ✅ `ApprovalExecutor` | — | | `ITriggerHandler` | `RuoVea.Workflow.Interface` | **type=51** 触发器业务逻辑 | ✅ `DefaultTriggerHandler`(直接放行) | 所有触发器节点直接放行 | | `IUserRelationshipService` | `RuoVea.Workflow` | 主管 / 角色 / 部门负责人查询 | ❌ **不注册** | ⚠️ **`SetType` 依赖组织关系的审批人来源全部解析为空**,节点挂起后无人可审 | | `IBusinessApprovalCallback` | `RuoVea.Workflow` | **type=90** 回调回写业务表 | ❌ **不注册** | ⚠️ 回调节点跳过并记 **Warning**,业务表不会被回写(流程照常推进) | | `IHasInitiator` | `RuoVea.Workflow` | 工作流数据暴露发起人 | 由宿主数据模型实现 | 审批人来源中含"发起人"的配置解析为空 | | `IHasFormFields` | `RuoVea.Workflow.Interface` | 工作流数据暴露表单字段 | 由宿主数据模型实现 | 条件分支**恒取不到值**,所有条件求值为假 | | `IHasBusinessRef` | `RuoVea.Workflow` | 工作流数据暴露 `BusinessType`/`BusinessId` | 由宿主数据模型实现 | 回调节点跳过(等同未注册回调) | | `IAutoResultWriter` | `RuoVea.Workflow.Interface` | 承接自动通过/拒绝结果 | 由宿主数据模型实现 | type=30/31 的结果无法被读取 | | `ITriggerResultWriter` | `RuoVea.Workflow.Interface` | 承接触发器解析出的动态审批人 | 由宿主数据模型实现 | 动态选人结果丢失,后续审批节点用静态配置 | | `IApprovalNodeWriter` | `RuoVea.Workflow` | 节点级审批人快照(放行前同步写回) | 由宿主数据模型实现 | ⚠️ **`BusinessApprovalEvaluator.IsApproved` 恒返回 `true`**(回调节点会记 Warning,见下节) | | `IApprovalNodeRemarkWriter` | `RuoVea.Workflow` | **节点级审批意见快照**(放行前同步写回) | 由宿主数据模型实现 | 流转树无法显示"谁写了什么意见"(意见仍经 `INotification` 广播) | | `IHasFormSchemas` | `RuoVea.Workflow.Interface` | 工作流数据暴露表单字段元信息(展示名/控件) | 由宿主数据模型实现 | 流程表单按**键名**渲染,页面显示 `amount` 而非「金额」 | | `IWorkflowLauncher` | `RuoVea.Workflow.Interface` | 提交审批时"按定义 Id 启动一条实例" | ❌ **不注册** | ⚠️ `WorkflowSubmitOrchestrator` 提交时明确失败("未注册工作流启动器") | | `IPendingWorkStore` | `RuoVea.Workflow.Services` | 超时/提醒/延迟登记项的持久化存储 | ❌ **不注册** | 三个登记处维持内存实现(重启丢未到期项);仅当调用 `AddRuoVeaPersistentRegistries()` 时才必须注册 | > **`IApprovalNodeWriter` 是最容易被忽略的一条**:未实现时没有任何审批快照,判定"整份流程里是否存在 refuse"退化为"没有记录 ⇒ 通过"。若你的业务依赖 type=90 回调节点区分通过/驳回,**必须实现它**。 > **接入 type=90 业务回调的完整步骤**(6 步 + 自检清单 + 运行时序)见下方 [场景一:业务单据 × 审批](#场景一业务单据--审批业务表为唯一事实来源)。 ### 审批节点「表单权限」:字段级只读 / 编辑 / 隐藏 设计器审批节点属性的第二个 Tab。作用:**控制"这个审批节点在处理审批时,能看 / 能改业务单据的哪些字段"**——把权限从"能不能审"细化到字段粒度。 数据链路共 5 环,缺任一环都表现为**静默失效**(不报错): | # | 环节 | 位置 | 说明 | | --- | --- | --- | --- | | ① | 设计器配置 | `approver.vue` Tab「表单权限」 | 选业务单据 → 铺字段 → 逐字段选 `只读/编辑/隐藏` | | ② | 存进节点定义 | `FlowNode.SubForms` / `FlowNode.FormPermissions` | `{ field: "供应商", operate: "edit" }`(`field` 存**中文展示名**) | | ③ | 流向图透出 | `FlowlongDefinitionService.MapNode` → `FlowTraceNode.FormPermissions` | 与 `AllowTransfer` 等同批透出 | | ④ | 展示名 → 业务属性 | `Web/src/config/businessFieldViews.ts` | `"供应商" → supplierName`,含控件类型 | | ⑤ | 详情页按权限渲染 | `inbound/component/inboundOrderView.vue` | edit 出控件、read 只读、hide 不渲染 | **①②④ 的字段来源是同一个注册表**:`businessFieldViews.ts` 的 `BUSINESS_FIELD_MAP`。设计器「选择子表单」列出的单据与字段、详情页把展示名还原成业务属性,都读它。 > 这里原先还有一层后端「表单目录」服务(`FormCatalogService` + `/Form/subs`、`/Form/Fields`),是写死 4 个子表单的演示 mock。它带来两个问题:**① 字段与真实业务重复**——入库单的字段同时写在后端 mock 和前端注册表里,改一边就漂移;**② 另 3 个子表单(采购/报销/请假)是虚构业务**,库里根本没有对应实体与页面。 > 已删除,改为设计器直接读业务注册表:三份标签收敛成一份,且「能配的表单」与「真实存在的业务单据」天然一致——**新增业务单据时只需在 `businessFieldViews.ts` 登记一行**。 **默认口径(重要)**:详情页**未配置权限的字段一律按只读处理**。原因有两条——详情页原有行为就是只读,审批人可编辑必须是显式配置才开启;设计器选中业务单据时会把该单据**所有**字段铺进权限表,所以一份正常配置本身就是全量的,未列出的字段只可能是跨单据残留。 > 铺字段时的初始权限由 `defaultOperateOf()` 决定:`submit: false` 的字段(单号、合计金额等)默认**只读**——它们在后端白名单里本就不接受审批人改写,默认给「编辑」只会配出一个保存时被拒的权限。 **落地方式**(以入库单为例): ``` 审批人点「查看入库单」→ 新标签页打开 /inbound/order/detail/:id?instanceId=... → 拉 /InboundOrder/Detail(参与者校验取数) → 拉 /Flowlong/Trace,定位「我所在的当前审批节点」(主链 status=running 且审批人含当前用户) → resolveFieldPermissions(字段定义表, 节点.FormPermissions) → 按权限渲染:edit→控件 + 「保存修改」按钮;read→只读文本;hide→整项不渲染 → 保存 → PUT /InboundOrder/UpdateByApprover(再次校验参与者 + 字段白名单) ``` **双层守卫**,前端权限只负责渲染,安全边界在后端: | 层 | 位置 | 职责 | | --- | --- | --- | | 前端 | `resolveFieldPermissions` + `inboundOrderView` | 按权限渲染,避免误操作 | | 后端 | `InboundOrderService.UpdateByApproverAsync` | **参与者校验**(同 `GetDetailAsync`)+ **字段白名单**(只允许 `订单日期/供应商/仓库/经办人`,单号、合计金额、状态、审批意见、实例引用一律不可改)+ 仅「审批中」可改 | 各字段为 `null` 表示"本次不修改",不参与 SQL `SET`,避免未提交的字段被清空。 **已知边界**: - 流程定义里没配表单权限 → 审批页全只读(安全默认,不会误开放编辑)。 - 当前用户不在"我所在的当前审批节点"内 → 全只读,不出保存按钮。 - 字段展示名与 `businessFieldViews.ts` 的 `label` 不一致(如手工改过节点配置)→ 该字段保持默认只读(不报错)。 - 字段联动、必填校验、明细行级权限均**未实现**——当前只覆盖单头标量字段的显隐与可编辑。 ### 流程表单字段的展示名(`formFieldSchemas`)与节点审批意见 工作流三处页面(「我的申请 / 我收到的 / 待我审批」)都要渲染两块之前缺失的东西:**表单字段的中文标签**、**每个审批人填的意见**。两者的共同约束是"不能改 `FormFields` 的结构"。 #### ① 表单字段元信息:与 `FormFields` **平行**,不嵌套 `FormFields` 是 `Dictionary`,**条件分支求值直接读它**(见 `ConditionalBranch`)。如果把值包成 `{ value, label }` 对象,所有比较都会失效,历史实例也反序列化不出来。因此标签另开一条平行链: | 环节 | 位置 | 说明 | | --- | --- | --- | | 契约 | `RuoVea.Workflow/Interface/IHasFormSchemas.cs` + `Dto/FormFieldSchema.cs` | `Key / Label / Control / Options / Unit`;未实现该接口则展示层回退到键名 | | 发起时传入 | `WorkflowLaunchRequest.FormFieldSchemas` → `WorkflowSubmitPlan.FormFieldSchemas` | 与 `FormFields` 同批交给引擎 | | 落库 | 宿主 `WorkflowData.FormFieldSchemas` | 与 `FormFields` 平级的持久化字段 | | 透出 | `FlowlongService.TraceAsync` → `trace.data.formFieldSchemas` | 与 `formFields` 一起下发 | | 渲染 | `Web/src/utils/formSchema.ts` 的 `buildFormRows()` | 三处页面共用,标签取 `label`,缺失回退键名;`control` 决定控件,`unit` 拼在值后 | ```csharp // 业务侧在提交审批时提供标签(Demo: InboundOrderService.SubmitAsync) FormFieldSchemas = new List { new FormFieldSchema { Key = "orderNo", Label = "入库单号" }, new FormFieldSchema { Key = "supplier", Label = "供应商" }, new FormFieldSchema { Key = "amount", Label = "合计金额", Unit = "元" } }, ``` > 为什么不由前端写死中文字典:加字段时忘加翻译是**静默**的(页面显示英文键),而这种映射本来就只有业务侧知道。放在提交处,字段与标签在同一个文件里改,漏改一眼可见。 #### ② 节点级审批意见:新增 `IApprovalNodeRemarkWriter` 此前审批意见只在 `ApprovalActionOptions.Remark` → `INotification` 广播路径上,**宿主不消费即丢失**;`ApprovalExtensionData` 在节点放行后又被引擎清空。所以意见同样需要一条"节点级快照": | 环节 | 位置 | 说明 | | --- | --- | --- | | 契约 | `RuoVea.Workflow/Interface/IApprovalNodeRemarkWriter.cs` | `SetNodeRemark(stepId, userId, remark)` | | 写回时机 | `Approval.cs` 的 `HandleAgree` / `refuse` / `back` / `redirect` / `append_*` 分支 | 与 `WriteNodeApproval` **同一时机**(放行/挂起前同步写回),保证流程结束后仍可还原 | | 落库 | 宿主 `WorkflowData.NodeRemarks`(`stepId → userId → remark`) | 与 `NodeApprovals` 平级 | | 透出 | `FlowTraceNode.Approvers[].Remark` | 与审批人状态一起下发 | | 渲染 | 三处页面的流转树 | 意见贴在该审批人名字后面,单行省略、悬停看全文 | **覆盖哪些动作**:同意 / 拒绝 / 退回(本人表态)+ 转交 / 前加签 / 后加签(把任务交给或插入他人)。后三者的说明同样是流程读者需要的上下文("为什么转交""为什么加签")。 > ⚠️ **转交 / 加签的动作人由 `IUserProvider` 解析**。这两个动作的载荷里只有 `TargetUser` / `TargetUsers`,**没有操作人字段**,引擎回退到 `_userProvider?.UserID`。宿主必须保证发布事件前已设置当前用户(Demo 的 `EventService` 是 `CurrentUserProvider.Current = ResolveCurrentUserId(request.UserId)`),否则 `userId` 为空、意见不会写回(静默)。 **为什么不直接扩展 `IApprovalNodeWriter`**:它是**已发布**的契约,宿主已实现;引擎包目标是 `netstandard2.0`,而默认接口方法(default interface methods)需要 .NET Core 运行时,加了会导致宿主编译期破碎。因此遵循本库既有的"可选扩展接口 + 引擎侧 `as` 判空"模式另开契约。**未实现时静默跳过**(意见仍通过 `INotification` 广播),不影响任何现有流程。 **留空不写入**:前端同意/拒绝时意见是可选的,留空不落库,避免在流转树上渲染出一对空引号。"没填意见"与"记录丢了"由审批状态本身区分(状态为 agree/refuse 即说明记录存在)。 契约测试见 `RuoVea.Workflow.Tests/ApprovalNodeRemarkTests.cs`(同意/拒绝/空意见/会签/转交/加签,各一条)。 ### 审批事件机制 - 审批节点内部以 `WaitForEvent` 挂起,**事件名固定为 `RuoVeaApproval`**,事件 Key 的格式为 `{工作流实例Id}.{执行指针Id}`。 - 宿主只需持有**实例 Id**,由 `ApprovalEventKeyHelper.FindEventKey(host, instanceId)` 反查出当前等待中的指针,再 `PublishEvent`。`IApprovalExecutor.Agree/Refuse/...` 已封装该流程。 - 审批节点放行后,引擎会**清空该指针的 `PersistenceData`**(即 `ApprovalExtensionData`),因此**流程结束后无法再从指针还原"每个节点由谁审批"**——这正是 `IApprovalNodeWriter` 节点级快照存在的理由。 - 不要在宿主侧另存一份"审批状态汇总字典"并在 `PublishEvent` 之后用 `Task.Delay` 写入:该写入晚于引擎推进,回调节点读到它时当前审批人**可能仍是 pending**(此竞态已有实测证据,见 `BusinessApprovalEvaluator` 类注释)。 ### 宿主端点调用约定(参数绑定的坑) Demo 的 `EventService` / `FlowlongService` 走**动态 WebApi**,方法签名决定参数从哪来。调错位置的典型症状是 **HTTP 200 但响应体 `code:400`,消息 `Value cannot be null. (Parameter 'g')`**——这是动态 WebApi 内部把参数拼进表达式时的报错,不是参数校验,很容易误判为业务错误。 **规则**:**显式标了 `[FromBody]` 的参数放 body;其余简单类型(`string id` 等)必须走 query。** | 端点 | 调用方式 | 实测 | | --- | --- | --- | | `POST /api/Event/Agree`
`POST /api/Event/Refuse` | **全部走 query**(无 body)
`?id={实例Id}&userId={操作人}&remark={意见}` | ✅ | | `POST /api/Event/Redirect`
`POST /api/Event/Back`
`POST /api/Event/Append`
`POST /api/Event/Urge`
`POST /api/Event/Cc`
`POST /api/Event/Withdraw`
`POST /api/Event/AddComment` | `id` 走 **query**,请求体走 **body**
`?id={实例Id}` + `{ targetUser / users / remark ... }` | ✅ | | `GET /api/Event/Comments` | `?id={实例Id}` | ✅ | | `DELETE /api/Workflow/Terminate/{id}` | id 走**路径段**(方法上标了 `[HttpDelete("{id}")]`) | ✅ | | `DELETE /api/Flowlong/Delete?id=` | id 走 **query**(`[HttpDelete]` 无路径段) | ✅ | > ❌ `POST /api/Event/Redirect` 把 `id` 放进 body → `code:400 / Value cannot be null. (Parameter 'g')`。 > ❌ 忘了带 `id`(body 与 query 都没有)→ 同样的报错,别去找业务代码。 **排查手段**:先用 curl 逐个 `-X` 实测 `-w "%{http_code}"`。**405 且 `allow: GET,HEAD`** 表示前端把 id 当路径段拼错(本该是 query);**200 但 body 里 `code:400 + Parameter 'g'`** 表示参数位置错了。前端调用示例见 `Web/src/api/business/workflow.ts`(`apiEventRedirectPost` 等已按上表实现)。 --- ## 🏢 业务场景接入说明 > 本章节以仓库 Demo `RuoVea.Workflow.Demo` 的**入库单**为例。业务单据活在自己的业务表与业务页面里,工作流只承载「审批」这一段生命周期。 ### 场景一:业务单据 × 审批(业务表为唯一事实来源) **核心原则**:引擎**绝不**持有业务数据的唯一副本。它只保留两个引用字段,业务数据始终以业务表为准。 ``` 业务表 InboundOrder 引擎 WorkflowData ────────────────── ────────────────── Id / OrderNo / Supplier / TotalAmount DocumentName Status ← 引擎驱动 InitiatorId InstanceId ─┐ FormFields{ amount, supplier, orderNo } DefinitionId ┘ BusinessType = "inbound_order" BusinessId = "123456789" ``` 落地需依次完成以下 6 步,**缺任何一步回调都不会生效**(引擎的安全默认是"静默跳过"): **① 工作流数据模型实现 `IHasBusinessRef`** —— 让引擎能从流程数据里取到业务主键 ```csharp public class WorkflowData : IHasBusinessRef, IHasInitiator, IHasFormFields, IApprovalNodeWriter /* ... */ { /// 业务类型标识(如 inbound_order) public string BusinessType { get; set; } /// 业务主键(业务表记录 Id 的字符串形式) public string BusinessId { get; set; } // 显式接口实现,避免与其它成员重名 string IHasBusinessRef.BusinessType => BusinessType; string IHasBusinessRef.BusinessId => BusinessId; } ``` > 接口只有两个**只读**属性;属性名与宿主自有属性重名时用显式实现(`string IHasBusinessRef.BusinessType => ...`),引擎侧是 `data as IHasBusinessRef` 取值。 **② 数据模型实现 `IApprovalNodeWriter`** —— 提供"审批结论"的判定依据(**最容易漏、漏了功能就是坏的**) `BusinessApprovalEvaluator.IsApproved` 读的不是运行时汇总字典,而是审批节点在**放行前同步写回**的节点级快照。宿主必须实现该接口承接快照: ```csharp public class WorkflowData : IApprovalNodeWriter /* ... */ { /// key = 审批节点执行指针 StepId public Dictionary NodeApprovals { get; set; } = new Dictionary(); // 显式接口实现:对外暴露只读视图 IReadOnlyDictionary IApprovalNodeWriter.NodeApprovals => NodeApprovals.ToDictionary(kv => kv.Key, kv => (IApprovalNodeSnapshot)kv.Value); public void SetNodeApproval(string stepId, IReadOnlyList approvers, IReadOnlyDictionary status) { if (string.IsNullOrEmpty(stepId)) return; NodeApprovals ??= new Dictionary(); NodeApprovals[stepId] = new NodeApproval { Approvers = new List(approvers ?? new List()), Status = new Dictionary(status ?? new Dictionary()) }; } } /// 单节点快照,实现引擎的只读契约 IApprovalNodeSnapshot public class NodeApproval : IApprovalNodeSnapshot { public List Approvers { get; set; } = new List(); public Dictionary Status { get; set; } = new Dictionary(); // 显式接口实现:对外暴露只读视图(可序列化字段保持 List/Dictionary) IReadOnlyList IApprovalNodeSnapshot.Approvers => Approvers; IReadOnlyDictionary IApprovalNodeSnapshot.Status => Status; } ``` > ⚠️ **未实现该接口 → `NodeApprovals` 为空 → 判定退化为"没有 refuse 记录 ⇒ 通过",驳回永远不会回写业务表**。失效本身不再无声:回调节点检测到快照为空时会记一条 Warning(`判定依据为空 … IsApproved 将恒为 true`),上线后监控 `Warn.log` 即可发现。但**别依赖告警兜底——依赖通过/驳回区分时必须实现它**。 **③ 启动流程时带上业务引用**(以 Demo 的 `FlowlongDefinitionService.StartAsync` 为例,末尾两个参数为业务引用) ```csharp var instanceId = await _definitions.StartAsync( definitionId, model.OrderNo, // documentName string.Empty, // approvers(走节点上的静态配置) formFields, // 条件分支求值依据 _currentUser.UserId.ToString(), model.Id.ToString(), // businessId ← 业务主键 "inbound_order"); // businessType ``` **④ 实现回写回调并注册** ```csharp public class InboundOrderApprovalCallback : IBusinessApprovalCallback { private const string BusinessTypeInboundOrder = "inbound_order"; private readonly ISqlSugarClient _db; public BusinessCallbackResult OnApprovalCompleted(BusinessApprovalContext context) { // 业务类型不匹配 → 不是我的业务对象,静默放行(一个宿主可以注册多个回调) if (!string.Equals(context.BusinessType, BusinessTypeInboundOrder, StringComparison.OrdinalIgnoreCase)) return new BusinessCallbackResult { Handled = false }; if (!long.TryParse(context.BusinessId, out var orderId)) return new BusinessCallbackResult { Handled = false }; var status = context.Approved ? InboundOrderStatusConst.Stocked : InboundOrderStatusConst.Rejected; _db.Updateable() .SetColumns(x => x.Status == status) .SetColumns(x => x.AuditRemark == context.ApproverSummary) .Where(x => x.Id == orderId) .ExecuteCommand(); return new BusinessCallbackResult { Handled = true }; } } ``` ```csharp // Program.cs / ServiceCollection 扩展中注册(单例,与 ITriggerHandler 等宿主扩展点一致) builder.Services.AddSingleton(); ``` > **一个宿主只能注册一个 `IBusinessApprovalCallback`**。多种业务类型时,在实现内按 `context.BusinessType` 分派,不匹配返回 `Handled = false`。 **⑤ 流程定义中接线**——回调节点(type=90)放在**所有审批路径的汇合点之后、结束节点之前,整条路径上只此一个**: ``` 发起人(0) → 部门主管审批(1) → 条件路由(4) ─┬─ 大额分支 → 财务审批(1) └─ 小额分支(默认,无审批) ↓ 汇合 业务回调(90) → 结束(-1) ``` 判定口径与"由哪条路径到达"无关:**整份 `NodeApprovals` 快照中存在任一 `refuse` 即驳回,否则通过**,因此天然幂等、可重入。 **⑥ 发起侧:业务表与流程的编排**(详见[场景四:事务边界与失败补偿](#场景四事务边界与失败补偿)) ```csharp // 1. 业务 DML:置「审批中」 model.Status = InboundOrderStatusConst.Approving; await _thisRepository.AsUpdateable(model).UpdateColumns(x => new { x.Status }).ExecuteCommandAsync(); try { // 2. 事务外启动工作流:只把业务引用与求值字段交给引擎,不塞业务数据副本 var instanceId = await _definitions.StartAsync( definitionId, model.OrderNo, string.Empty, formFields, _currentUser.UserId.ToString(), model.Id.ToString(), // businessId "inbound_order"); // businessType // 3. 回填实例 Id,供「查看审批流向」跳转 model.InstanceId = instanceId; await _thisRepository.AsUpdateable(model).UpdateColumns(x => new { x.InstanceId }).ExecuteCommandAsync(); } catch { // 4. 补偿:启动失败 → 回滚为草稿态并清空引用,再交给全局中间件 await _thisRepository.AsUpdateable(model) .SetColumns(x => x.Status == InboundOrderStatusConst.Draft) .SetColumns(x => x.InstanceId == null).Where(x => x.Id == model.Id) .ExecuteCommandAsync(); throw; } ``` **完成后的自检清单**(任一项为"否"则回调不会按预期工作): | # | 检查项 | 否的后果 | | --- | --- | --- | | 1 | 数据模型实现了 `IHasBusinessRef` 且 `BusinessId` 非空 | 节点静默跳过 | | 2 | 数据模型实现了 `IApprovalNodeWriter` | 判定恒为"通过",驳回不回写 | | 3 | `IBusinessApprovalCallback` 已注册到 DI | 节点静默跳过 | | 4 | 回调节点(type=90)在定义中,且位于汇合点之后 | 根本不会执行 | | 5 | 审批路径能汇合到回调节点(分支子链的出口会在 join 处被通配 outcome 接住) | 该路径的结论不会回写 | | 6 | 回调实现按 `BusinessType` 判归属,且写入幂等 | 多业务串扰 / 重试重复写脏数据 | > **`IBusinessApprovalCallback` 是同步方法**。回调节点运行在 WorkflowCore 的消费者线程上(不在 HTTP 请求作用域内),宿主实现内部如需异步落库请自行同步等待(`.GetAwaiter().GetResult()`)。这与 `INotification` 的同步签名保持一致。 #### 运行时序 ``` ① InboundOrderService.SubmitAsync ├─ 业务表 Status = 审批中 └─ StartAsync(..., businessId: model.Id, businessType: "inbound_order") ② 引擎推进到审批节点 └─ 审批人在「待审核」页同意/拒绝 → Approval.HandleAgree └─ 放行前 WriteNodeApproval() 同步写回 NodeApprovals[stepId] ← 关键:先于引擎推进 ③ 引擎推进到业务回调节点(type=90,汇合点之后) └─ BusinessCallbackNode.Run() ├─ data is IHasBusinessRef 且 Callback != null 且 BusinessId 非空 ← 任一不满足则跳过 │ └─ 有业务引用但回调未注册 → LogWarning「未注册 IBusinessApprovalCallback,业务表不会回写」 ├─ BusinessApprovalEvaluator.HasSnapshot(data) ← 为空则先 LogWarning「判定依据为空,IsApproved 恒为 true」 ├─ BusinessApprovalEvaluator.IsApproved(data) ← 读 NodeApprovals,存在 refuse 即驳回 ├─ BusinessApprovalEvaluator.Summarize(data) ← 生成 "u1:agree,u2:refuse" └─ Callback.OnApprovalCompleted(context) ④ InboundOrderApprovalCallback └─ _db.Updateable().SetColumns(Status = 已入库/已驳回).Where(Id == orderId) ⑤ 节点无条件 ExecutionResult.Next() → 流程继续(回调不改流程走向) ``` #### 如何验证回调已生效 回调此前是**静默失败**的(跳过、不匹配都不抛异常)。两种最危险的失效现已升级为 **Warning 日志**: | 失效 | 触发条件 | 日志 | | --- | --- | --- | | 回写目标无人接收 | 实例有 `BusinessId` 但 DI 未注册 `IBusinessApprovalCallback` | `业务回调节点已到达但未注册 IBusinessApprovalCallback,业务表不会回写。instanceId=… businessType=… businessId=…` | | 结论判定无依据 | `IHasBusinessRef` 已实现但 `IApprovalNodeWriter` 未实现(快照为空) | `业务回调节点判定依据为空(…未实现 IApprovalNodeWriter 或尚无审批快照),IsApproved 将恒为 true,驳回可能被误判为通过。instanceId=… businessId=…` | > 告警**不阻断流程**(回调节点仍返回 `ExecutionResult.Next()`)——数据已经丢了,再让流程挂起只会雪上加霜。因此上线后请监控 `log/*/Warn.log` 中的「业务回调节点」条目。 按以下顺序排查,能一次性定位断在哪一环: 1. **回调实现内打日志**——最直接的证据: ```csharp _logger.LogInformation("业务回调触发:实例 {InstanceId} 类型 {Type} 主键 {Id} 结论 {Approved} 意见 {Summary}", context.InstanceId, context.BusinessType, context.BusinessId, context.Approved, context.ApproverSummary); ``` 2. **看日志有无该行**: - **没有** → 断在 ①~⑤:依次确认数据模型实现了 `IHasBusinessRef`、`BusinessId` 非空、回调已注册、定义里有 type=90 节点;若 `Warn.log` 里有「未注册 IBusinessApprovalCallback」说明回调未注册; - **有,但 `Approved` 恒为 `true`**(驳回也判通过)→ 断在第 ② 步:数据模型未实现 `IApprovalNodeWriter`,或 `SetNodeApproval` 没被真正写进字典。`Warn.log` 里会伴随「判定依据为空」告警。 3. **查业务表**:`InboundOrder.Status` 是否从「审批中」变为「已入库」/「已驳回」。 4. **查流程实例**:实例是否已 `Complete`(若挂在某审批节点,说明还没走到回调)。 5. **单测佐证**(仓库 `RuoVea.Workflow.Tests`): - `BusinessCallbackNodeTests` —— 覆盖"未注册回调静默跳过""BusinessId 为空跳过""节点级快照完整而汇总状态仍是 pending"的竞态探针; - `BusinessCallbackNodeTests.BusinessCallbackWarningTests` —— 覆盖两种静默失效的 Warning 告警; - `BusinessApprovalEvaluatorTests` —— 覆盖判定口径(存在 refuse 即驳回)。 6. **端到端验证**:走 Demo 的完整业务链路(建单 → 提交审批 → 审批 → 查业务表状态回写)。 旧的 `examples/verify-inbound-order.mjs` 脚本依赖已移除的 `RuoVea.Workflow.WebApi`,其地址与路由亦按旧项目写死,**当前不可直接运行**。 #### 为什么判定要读节点快照而不是运行时字典 `BusinessApprovalEvaluator` 的类注释记录了两条实测结论,**接入时不要"优化"回读下面两种数据**: | 数据源 | 为什么不能用 | | --- | --- | | 宿主自存的 `ApprovalStatus` 汇总字典 | 它在 `PublishEvent` 之后由 `Task.Delay` 才写入,晚于引擎推进。回调节点执行时当前审批人**可能仍是 pending**(竞态已有实证:两人会签全同意时,快照为 `[u1:agree,u2:agree]` 而汇总字典仍为 `[u1:pending,u2:pending]`) | | `ApprovalExtensionData.Status`(审批指针持久化数据) | 审批节点放行后引擎会**清空该指针的持久化数据**,流程结束时已取不到 | 节点级快照由审批节点**放行前同步写回**(`Approval.HandleAgree` / `WaitForApproval` → `WriteNodeApproval`),因此回调节点读它时数据必然完整。 #### 其它用途:只做"通知式"回调 回调节点不限于回写状态。只要数据模型实现 `IHasBusinessRef`,就可以在 `OnApprovalCompleted` 里做任何事:写审批日志表、发消息、调外部系统、触发下游单据。若不打算回写业务表,可只注册一个"只记日志"的实现: ```csharp public class AuditOnlyCallback : IBusinessApprovalCallback { private readonly ILogger _logger; public BusinessCallbackResult OnApprovalCompleted(BusinessApprovalContext context) { _logger.LogInformation("实例 {InstanceId} 业务 {Type}#{Id} 结论={Approved} 意见={Summary}", context.InstanceId, context.BusinessType, context.BusinessId, context.Approved ? "通过" : "驳回", context.ApproverSummary); return new BusinessCallbackResult { Handled = true }; } } ``` #### 多业务类型的分派写法 一个宿主通常有多个业务单据共用一套引擎。由于只允许注册一个实现,用**门面 + 分派**模式: ```csharp public class CompositeApprovalCallback : IBusinessApprovalCallback { private readonly IEnumerable _handlers; public CompositeApprovalCallback(IEnumerable handlers) => _handlers = handlers; public BusinessCallbackResult OnApprovalCompleted(BusinessApprovalContext context) { foreach (var h in _handlers) { var r = h.OnApprovalCompleted(context); if (r.Handled) return r; // 谁认领谁处理,其余静默放行 } return new BusinessCallbackResult { Handled = false, Message = $"无回调认领 {context.BusinessType}" }; } } ``` > 注册时把各业务的具体实现注册为 `IBusinessApprovalCallback`(注意排除门面自身,避免递归),再把 `CompositeApprovalCallback` 显式注册为对外的那一个。 ### 场景二:条件分支(发起时求值) 条件节点(type=3)的 `ConditionList` 对一个**二维数组**求值:外层多个数组之间是「或」,内层条目之间是「与」。 ```csharp new FlowNode { NodeName = "大额(>1000)", NodeKey = "flk-big", Type = 3, ConditionList = new List> { new List { new FlowCondition { Label = "金额", Field = "amount", Operator = ">", Value = "1000" } } } } ``` 要点: - 求值数据源是 `IHasFormFields.FormFields`,**在发起时一次性写入**。所以 `formFields["amount"]` 的值就是发起那一刻的合计金额。 - **默认分支**:`ConditionList` 留空**且放在最后一位**的子节点被判定为兜底分支。务必保留一个,否则条件全不命中时流程无处可去。 - 转义语义:外层数组之间是「或」(任一组成立即命中),内层是「与」(组内全部满足才命中)。 - **每次推进时求值(预留扩展)**:条件求值的入口是 `ConditionalBranch`/`BranchGate` 读 `WorkflowData.FormFields`,把它换成"实时读业务表最新值"即可实现推进时求值,**接口无需改动**。当前版本只做发起时求值。 ### 场景三:主干拒绝 vs 分支内拒绝 这是**审批 × 分支叠加时最容易误传的坑**——网上流行的说法(含本仓 README 的早期版本)是「分支内拒绝会走引擎默认终止、不回写业务表」,**实测不成立**。行为完全由引擎的 Outcome 匹配规则决定。 审批节点"同意"返回 `Outcome("agree")`、"拒绝"返回 `Outcome("refuse")`。引擎匹配规则是:**只带 `NextStep` 而无 `Value` 的出口是通配**,任何 Outcome 都能匹配(WorkflowCore 仅在 `Outcomes.Count > 1` 时才按 `Value` 精确匹配)。于是: | 位置 | 配置 | 同意 | 拒绝 | | --- | --- | --- | --- | | **主干**审批节点 | 挂一个 `Value="refuse"` 的出口指向回调节点 | 走线性 `NextStep` 继续主干 | 落到 `refuse` 出口 → **直达回调节点 → 回写业务表** | | **分支内**审批节点 | 未挂 `refuse` 出口,分支子链末尾汇入 join | 走线性 `NextStep` 继续分支 | **同样落到通配出口 → 汇入 join → 回调节点 → 回写业务表** | 原因:分支子链末尾指向汇合点(join)的那个出口**也是通配的**(只有 `NextStep`、没有 `Value`),所以拒绝与同意一样会被它接住,流程照常推进到回调节点。判定依据 `BusinessApprovalEvaluator` 读的是节点级快照而非"从哪条路径到达",因此结论仍然正确。 **实测证据**(`RuoVea.Workflow.Tests/BranchRejectProbeTests.cs`,条件路由 → 大额分支财务会签 → 回调节点): ``` ===== 分支内审批拒绝 ===== 主干同意后是否停在分支节点: True 实例状态: Complete 回调调用次数: 1 回调: approved=False summary=142307070910564:agree,142307070910562:refuse IsApproved: False ``` 即:**分支内拒绝确实到达了回调节点,且正确报告 `approved=False`**。 主干节点的 `refuse` 出口配置写法(仍然推荐,但它是**显式的语义表达**,不是"分支回写"的必要条件): ```csharp new FlowNode { NodeName = "部门主管审批", NodeKey = "flk-inbound-mgr", Type = 1, // 拒绝出口:refuse → 业务回调;未设 Value 的出口由 BuildWorkflowDefinition 补为主干下一步(同意) Routes = new List { new FlowRoute { NodeName = "拒绝", Value = "refuse", NextStep = "flk-inbound-callback" } } } ``` > 💡 **不挂 `refuse` 出口的主干节点,拒绝同样能到达回调节点**(`BranchRejectProbeTests.探针_主干拒绝且无refuse出口` 实测 `Complete` + 回调 1 次 + `approved=False`)——因为主干审批后续步骤的出口同样是通配的。挂 `refuse` 出口的价值在于**让"拒绝去哪"在流程定义里显式可见**,便于设计器与运维阅读,而不是修正某种引擎缺陷。 **因此接入时无需为"关键拒绝路径"做主干/分支的位置取舍**。真正的必要条件只有一条:**确保审批路径能汇合到回调节点**(回调节点放在汇合点之后、结束之前,全流程只此一个)——这一点见「场景一」的自检清单。 ### 场景四:事务边界与失败补偿 如果宿主业务库与引擎持久化使用**不同连接**(例如业务表走 SqlSugar、引擎表走 EF Core + Sqlite,即使共用同一个 `.db` 文件),**两者不能共享事务**。此时「提交审批」必须按固定顺序编排,并在失败时显式补偿: ```csharp // 注意:本方法刻意不加 [UnitOfWork] —— 事务无法跨越引擎 public async Task SubmitAsync(InboundOrderSubmitDto data) { // 1. 前置校验:状态必须是草稿、必须有明细、流程定义必须存在 // 2. 业务 DML:置「审批中」,合计金额以服务端重算为准 CalculateTotalAmount(model); model.Status = InboundOrderStatusConst.Approving; await _thisRepository.AsUpdateable(model) .UpdateColumns(x => new { x.Status, x.TotalAmount }) .ExecuteCommandAsync(); try { // 3. 事务外启动工作流:只把业务引用与求值字段交给引擎,不塞业务数据副本 var instanceId = await _definitions.StartAsync(definitionId, model.OrderNo, string.Empty, formFields, _currentUser.UserId.ToString(), model.Id.ToString(), "inbound_order"); // 4. 业务 DML:回填实例 Id / 流程定义 Id,供「查看审批流向」跳转 model.InstanceId = instanceId; model.DefinitionId = definitionId; await _thisRepository.AsUpdateable(model) .UpdateColumns(x => new { x.InstanceId, x.DefinitionId }) .ExecuteCommandAsync(); return restful; // OK } catch { // 5. 补偿:启动/回填失败 → 回滚为草稿态并清空工作流引用,然后原样抛出交给全局中间件 await _thisRepository.AsUpdateable(model) .SetColumns(x => x.Status == InboundOrderStatusConst.Draft) .SetColumns(x => x.InstanceId == null) .SetColumns(x => x.DefinitionId == null) .Where(x => x.Id == model.Id) .ExecuteCommandAsync(); throw; } } ``` 要点: - **顺序固定**:业务 DML → 事务外启动工作流 → 业务 DML 回填。不要试图用一个大事务包住 `StartWorkflow`。 - 这里的 `try-catch` 是**补偿逻辑而非异常处理**:捕获后必须先回滚业务状态再 `throw`,交回全局中间件兜底提示。这是"Service 层不写 try-catch"规则的**唯一合理例外**,应在注释中说明原因。 - **补偿是必要的**:否则工作流启动失败时,单据会永远卡在「审批中」而无实例可查。 #### 不要每个业务都抄一遍:用引擎侧的 `WorkflowSubmitOrchestrator` 收敛编排 上面这段顺序是**与业务无关的**——新增第二种单据时再抄一遍,等于把「定义校验 + 事务外启动 + 回填 + 补偿」四处复制到 N 个 Service 里,任何一处顺序改错都是一次静默的脏数据。因此编排器下沉到了引擎包 `RuoVea.Workflow`,业务侧只描述差异点: ```csharp public class InboundOrderService // 注入引擎的 WorkflowSubmitOrchestrator { public async Task SubmitAsync(InboundOrderSubmitDto data) { // 1. 业务校验(草稿态、明细非空)——留在业务侧 var outcome = await _submitter.SubmitAsync(new WorkflowSubmitPlan { DefinitionId = definitionId, DocumentName = model.OrderNo, InitiatorId = _currentUser.UserId.ToString(), BusinessId = model.Id.ToString(), BusinessType = "inbound_order", FormFields = new Dictionary { ["amount"] = model.TotalAmount, /* ... */ }, BeforeStart = async () => { /* 置「审批中」+ 服务端重算 */ }, AfterStart = async instanceId => { /* 回填 InstanceId / DefinitionId */ }, OnFailed = () => { /* 回滚为草稿并清空工作流引用 */ } }); if (!outcome.Success) { restful.Message = outcome.Message; return restful; } restful.Data = outcome.Data; // WorkflowSubmitResult { InstanceId, DefinitionId } restful.Code = CodeStatus.OK; return restful; } } ``` | 职责 | 归属 | | --- | --- | | 流程定义存在性校验、启动参数装配、`try{catch{补偿; throw}}` 顺序 | `WorkflowSubmitOrchestrator`(引擎侧,全局一处) | | 「草稿态才能提交」「必须有明细」等业务规则 | 业务 Service | | 三段业务 DML(前置 / 回填 / 补偿) | 业务 Service 以委托提供 | | "按定义 Id 启动一条实例"这一动作本身 | 宿主实现的 `IWorkflowLauncher` | **为什么启动动作要单独抽一个 `IWorkflowLauncher`**:编排器不该知道"定义存在哪、数据模型长什么样"。引擎只定义契约,宿主提供实现(Demo 用 `FlowlongWorkflowLauncher` 适配到 `FlowlongDefinitionService`)。不要用"往引擎塞一个默认实现"来省这一步——那会把宿主形态倒灌进组件包。 > `WorkflowSubmitOrchestrator` 由 `AddRuoVeaApproval()` 以 `Scoped` 注册,构造时用 `GetService()` 取启动器;**未注册启动器时提交会明确失败**("未注册工作流启动器…"),而不是静默启动出空实例让单据卡在「审批中」。 > > 返回值不是 `RestfulResult`(那是宿主的返回体约定),而是 `WorkflowSubmitOutcome`,由业务侧自行组装返回体——引擎不反向依赖任何 Web 框架。 > > 契约测试见 `RuoVea.Workflow.Tests/WorkflowSubmitOrchestratorTests.cs`:锁死顺序与补偿语义(顺序错了表现为静默脏数据,必须在测试层拦住)。 ### 场景五:审批人来源解析 审批节点的 `SetType` 决定审批人从哪里来,各来源对宿主的依赖不同: | `SetType` | 来源 | 依赖 | 注意事项 | | --- | --- | --- | --- | | `1` | 指定成员(`NodeAssigneeList`) | 无 | **最稳定**,Demo 与首次接入推荐 | | `2` | 按发起人的上级(`ExamineLevel` 指定第几级) | `IUserRelationshipService` + `IHasInitiator` | ⚠️ 未注册组织关系服务或组织树为空时,**解析结果为空列表,节点挂起后无人可审** | | 其它 | 角色 / 部门负责人 / 发起人自己 | `IUserRelationshipService` | 同上;角色来源另受 `RoleAllParticipate` 影响 | > **实测教训**:Demo 库未维护上下级关系,`SetType=2` 解析出空审批人列表,流程挂起后无人能审批。改回 `SetType=1` + 显式 `NodeAssigneeList` 后立刻打通。**接入时先用指定成员验证链路,再切换到组织关系来源。** 多人审批由 `ExamineMode` 决定:`1`=顺序、`2`=会签(全部同意)、`3`=或签(任一同意)、`4`=票签(按 `FlowAssignee.Weight` 计权重,同意权重和 / 总权重 > 50% 才通过)。 ### 场景六:超时 / 提醒 / 延迟 三者都依赖 `AddRuoVeaApprovalScanners()` 注册的后台扫描器,且都基于 `BackgroundService` 由宿主托管生命周期。 | 能力 | 节点配置 | 行为 | | --- | --- | --- | | 审批超时自动推进 | `TermAuto` / `Term`(小时) / `TermMode` | 到点由 `ApprovalTimeoutScanner` 自动同意或拒绝(`TermMode=1` → 拒绝) | | 到点提醒 | `RemindAuto` / `RemindMode` / `RemindValue` / `RemindUnit` / `RemindTime` | 到点由 `ApprovalReminderScanner` 记录提醒 | | 延迟等待 | type=50,`delayValue` / `delayUnit` | 到点由 `DelayScanner` 放行 | > 扫描器读取的是引擎内部登记处(`IApprovalTimeoutRegistry` / `IApprovalReminderRegistry` / `IDelayRegistry`)。这些登记处**默认是内存实现**:进程重启后未到期的登记会丢失,节点将一直挂起。生产环境用 `AddRuoVeaPersistentRegistries()` 换成持久化版(见下)。 **持久化登记处(生产建议)** 引擎提供三个持久化版登记处 + 一个存储抽象,落地只需两步: ```csharp // ① 宿主实现 IPendingWorkStore:继承 JsonPendingWorkStore,只写「存一行 / 原子取走并删除到期的行」。 // JSON 序列化与异常兜底(存储故障不向上抛,转交 OnStoreError)已由基类固化。 public class SqlSugarPendingWorkStore : JsonPendingWorkStore { protected override void SaveRaw(string kind, string payload, DateTime dueAtUtc, string instanceId, string stepId) => _db.Insertable(new PendingWorkItem { Id = IdGenerator.Id, Kind = kind, Payload = payload, /* ... */ }).ExecuteCommand(); protected override IReadOnlyList TakeExpiredRaw(string kind, DateTime nowUtc) { var rows = _db.Queryable().Where(x => x.Kind == kind && x.DueAtUtc <= nowUtc).ToList(); // ... 按主键删除后返回被删行(取出即删除) } protected override void OnStoreError(Exception ex, string op, string kind) => _logger.LogWarning(ex, "待处理工作项{Op}失败,类别 {Kind}", op, kind); // 务必记日志 } // ② 注册存储 + 替换三处登记处(必须在 AddRuoVeaApproval() 之后) builder.Services.AddSingleton(); builder.Services.AddRuoVeaPersistentRegistries(); ``` | 要素 | 位置 | 说明 | | --- | --- | --- | | `IPendingWorkItem` | 引擎 | 三类登记项的统一到期视图(`DueAtUtc`),使它们可共用一份存储 | | `IPendingWorkStore` | 引擎 | 存储抽象:`Save(kind, item)` / `TakeExpired(kind, nowUtc)` | | `JsonPendingWorkStore` | 引擎 | JSON 基类,替宿主挡掉序列化与异常处理 | | `PendingWorkKindConst` | 引擎 | 类别常量(`approval_timeout` / `approval_reminder` / `delay`),落库值不可变更 | | `Persistent*Registry` | 引擎 | 三个持久化登记处,语义与内存版完全一致,只是"存哪里"不同 | > ⚠️ **`TakeExpired` 必须"取出即删除"**,且多实例部署时要保证同一条记录只被一个实例取到(否则同一条超时/延迟会被重复发布,流程被推进两次)。契约详见 `IPendingWorkStore` 注释。 > > ⚠️ **存储写失败不抛异常**(转交 `OnStoreError`)。理由:登记项丢失只让流程少一次自动推进,而抛异常会中断审批节点挂起本身。因此 `OnStoreError` **必须记日志**,否则又变成静默失败。 > > Demo 侧已接入(`SqlSugarPendingWorkStore` + `PendingWorkItem` 表 + `DbSeedData` 建表),可作参考实现。 ### 场景七:抄送与触发器 **抄送(type=2)**:审批人列表直接取自 `NodeAssigneeList`,经 `INotification` 广播。未注册自定义 `INotification` 时使用默认实现(通知被丢弃,不报错)。 **触发器(type=51)**:流程推进到该节点时回调宿主 `ITriggerHandler.HandleAsync`,返回的 `TriggerResult` 决定: - `Pass=false` → **流程终止**(可用于"自动拒绝"); - `ResolvedApprovers` → 动态审批人列表,写入流程数据供**后续审批节点**读取(可做"按表单/接口规则选人"); - `Note` → 供流程展示与调试。 ```csharp public class HostTriggerHandler : ITriggerHandler { public async Task HandleAsync(TriggerContext context) { // context: InstanceId / NodeKey / InitiatorId / Params(节点配置 JSON) / FormFields return await Task.FromResult(new TriggerResult { Pass = true, ResolvedApprovers = new[] { "u1", "u2" }, Note = "按表单规则解析出 2 名审批人" }); } } ``` > 未注册 `ITriggerHandler` 时使用 `DefaultTriggerHandler`(直接放行),触发器节点不影响流程。 ### 场景八:前端接线 业务模块的页面应挂在**业务菜单**下,而不是工作流菜单下。只有「提交审批」与「查看审批流向」两个动作会碰到工作流: | 前端产物 | 说明 | | --- | --- | | `Web/src/api/business/inbound.ts` | 业务 API:列表/增删改走业务表;另加 `Submit`(推入工作流) | | `Web/src/views/business/inbound/index.vue` | 列表页:状态列按 `status` 用 `` 着色,操作列按状态显隐编辑/删除/提交审批 | | `Web/src/views/business/inbound/component/editInbound.vue` | 主子表编辑弹窗(`el-table` 可编辑明细行) | **路由映射规则**:后端菜单种子的 `Component` 字符串经 `import.meta.glob('../views/**/*.vue')` 映射到页面文件。例如 `Component = "/business/inbound/index"` → `Web/src/views/business/inbound/index.vue`。 **状态由引擎驱动**,前端只读不写: | `status` | 含义 | 标签色 | | --- | --- | --- | | `0` | 草稿 | info | | `1` | 审批中 | warning | | `2` | 已入库(审批通过回写) | success | | `3` | 已驳回(审批拒绝回写) | danger | **操作列显隐**:`status === 0` 才显示「编辑 / 删除 / 提交审批」;`instanceId` 非空才显示「查看审批流向」。 **查看审批流向零新增组件**——直接跳既有流向路由: ```typescript const viewTrace = (row: any) => { router.push(`/workflow/processTrace/${row.definitionId}/${row.instanceId}`); }; ``` **明细行的动态编辑**(主子表弹窗):`` 每行放 `el-input` / `el-input-number`,底部按钮 `push` 空行、行内按钮 `splice` 删行。**合计金额前端实时算只为体验,后端提交时仍会重算**。 #### 审批页如何展示业务单据:登记详情页路由,不要在审批页重画一份 工作流的「待我审批 / 我收到的 / 我的申请 / 流程审批」四处页面都需要让审批人看到业务单据。**不要在这些页面里内嵌一张"业务单据展示副本"**——业务单据一多,审批侧就要为每种单据维护一份展示实现,与业务侧自己的页面双写,字段与布局必然漂移。 约定:**业务单据长什么样,只由业务模块自己定义一次**(一个详情页路由);工作流侧只登记 `businessType → 详情页路由`,审批时给一个入口跳过去。 | 前端产物 | 说明 | | --- | --- | | `Web/src/config/businessViews.ts` | 注册表 `BUSINESS_VIEW_MAP`:`businessType → { label, route }`。新增单据只加一行 | | `Web/src/views/business/workflow/component/BusinessDocLink.vue` | 审批侧统一入口组件:已登记则渲染「查看{单据名}」按钮(新标签页打开),未登记则降级渲染内联兜底组件 | | `Web/src/views/business/inbound/detail/index.vue` | 业务模块自己的详情页。列表页「查看」与审批页跳转**共用同一个页面** | | `Web/src/utils/openRoute.ts` | `openRouteInNewTab(path)`:hash 模式下拼 `origin + pathname + '#' + path` 后 `window.open`,同源同 LocalStorage,登录态天然复用 | 关键点: - **`?instanceId=` 决定取数方式**。业务详情页同时服务两个入口:不带 `instanceId` 走业务侧接口(按 `Creator` 隔离,自己人查看);带 `instanceId` 走 `/InboundOrder/Detail`(不做 Creator 隔离——审批人不是发起人,按创建者过滤必然查不到),改为校验调用者是该工作流实例的**参与者**(发起人 / 节点审批人 / 抄送人)。守卫是双重的:先比对单据登记的实例 Id 与调用者声明的实例 Id 是否一致,再判参与者;实例查不到时**默认拒绝**。 - **菜单种子必须给审批人角色授权**。这套 Demo 是后端控制路由,菜单可见性就等于路由存在性——没授权 `inboundOrderDetail`,审批人在新标签页打开就是 404。超级管理员走 `IsSuperAdmin` 分支拿全表无需授权,普通角色(审批人绑的 `common_role`)必须补 `SysRoleMenu`。 - **兜底不是可选项**。历史/第三方流程定义里的 `businessType` 未必都登记过,`BusinessDocLink` 对未登记类型降级渲染 `BUSINESS_PANEL_MAP` 里的内联面板,两者都没有才显示"尚未接入"。新单据应优先登记路由,而不是往兜底表里加。 - 详情页默认是**只读**的:新增/编辑仍走列表页的编辑弹窗(`editInbound.vue`),详情页负责展示 + 提供「查看审批流向」回跳。审批动作(同意/拒绝)留在工作流页,不混进业务页。 唯一的例外是审批人从工作流页跳入时:若当前审批节点配了「表单权限」且其中有 `edit` 字段,详情页进入可编辑态并出现「保存修改」按钮,只开放**该节点显式授权的字段**(见上方[审批节点「表单权限」](#审批节点表单权限字段级只读--编辑--隐藏))。 > 已验证:`Web/verify-business-doc.mjs`(Playwright headless)覆盖「列表查看 → 详情路由」「审批视角带 instanceId 打开详情」两条链路。 ### ⚠️ 已知限制与注意事项 接入前请确认以下各条已评估: | # | 事项 | 影响 | 处理方式 | | --- | --- | --- | --- | | 1 | **未注册 `IBusinessApprovalCallback`** | 回调节点跳过(现已记 Warning),业务表不回写 | 必须注册,否则 type=90 形同虚设 | | 2 | **未实现 `IApprovalNodeWriter`** | `IsApproved` 恒返回 `true`,所有流程都被判为"通过"(现已记 Warning) | 依赖通过/驳回区分时必须实现 | | 3 | **未注册 `IUserRelationshipService`** | 组织关系类审批人来源解析为空,节点无人可审 | 注册实现,或改用指定成员 `SetType=1` | | 4 | **业务库与引擎不能共享事务** | 需要补偿逻辑兜底 | 见「场景四」 | | 5 | **引擎推进是异步的** | `PublishEvent` 后实例状态不会立即变化 | 校验/测试需轮询等待,不要立即断言 | | 6 | **审批放行后指针 `PersistenceData` 被清空** | 流程结束后无法从指针还原审批人 | 依赖 `IApprovalNodeWriter` 节点级快照 | | 7 | **超时/提醒/延迟登记处默认是内存实现** | 进程重启后未到期登记丢失,节点一直挂起 | 用 `AddRuoVeaPersistentRegistries()` + 宿主 `IPendingWorkStore` 替换,见「场景六」 | | 8 | **type=20/54 暂未实现** | 兜底为自动通过 | 暂勿在关键路径使用,或自行扩展 `CreateStep` | | 9 | **新增节点 type 需改两处** | 漏改 `CreateStep` 会退化成"开始"节点 | 同步改 `CreateStep` 与 `DefaultName` | | 10 | **表单权限的字段名是中文展示名** | 与注册表 label 不一致时权限静默失效(该字段保持只读) | 展示名由 `businessFieldViews.ts` 统一提供,设计器选单据时自动铺入,勿手改 | | 11 | **表单权限只覆盖单头标量字段** | 明细行级权限、字段联动、必填校验均未实现 | 需要时在 `UpdateByApproverAsync` 白名单与视图层自行扩展 | | 12 | **回调节点的两种失效只记 Warning,不阻断** | 宿主未注册回调 / 未实现 `IApprovalNodeWriter` 时数据会丢,日志是唯一线索 | 上线后监控 `Warn.log` 中的「业务回调节点…」条目,见「场景一」自检清单 | --- ## 🛡️ 设计理念 - **底座来自官方 NuGet 包** `WorkflowCore`(v3.20.0,netstandard2.0),引擎核心 / DSL / Users / Persistence 全部沿用官方实现,保证与官方生态完全兼容。 - **审批语义为本库独有**(设计灵感源自 ApprovalFlow / MicroWorkflow,依据木兰 PSL v2 授权溯源):`Approval`、`CC`、`Connector`、`Transaction` 等原语 + `IUserProvider` / `IApprovalExecutor` / `INotification` 业务接口。 --- ## 🗺️ 许可证 MIT(保留 Daniel Gerlag 2016 版权声明)授权。详见 [LICENSE](./LICENSE)。