工作流
XiHan.BasicApp.Workflow 是四个一等业务模块之一,坐落在框架 工作流引擎 之上,把「图执行引擎」变成一套可运营的审批系统:定义在管理端画、审批人按组织架构规则解析、业务单据挂到流程上走审批、审批人在审批中心办理,状态落库不怕重启。
引擎本身的机制(书签、活动、表达式、定时器、受理人规则、结果结束节点)见 框架 · Workflow。本页只讲 BasicApp 这一层加了什么。
模块做了什么
模块类 [DependsOn] 同时挂 XiHanBasicAppSaasModule 与 XiHanWorkflowModule,ConfigureServices 按职责分成几段:
services.AddWorkflowStores(); // ① Replace 掉框架的内存存储
services.AddWorkflowAssigneeProviders(); // ② 组织架构受理人规则(角色 / 部门 / 岗位 / 主管)
services.AddWorkflowBusinessIntegration(); // ③ 审批业务接入(发起、回写、撤回、进度)
services.AddWorkflowFieldSecurityEntities();
services.AddWorkflowDataSeeders(); // ④ 权限与菜单种子
services.AddWorkflowEventHandlers(); // ⑤ 待办通知与抄送记录仓储与应用服务全部交给约定注册。
① 存储持久化(最关键的一层)
框架默认的三个存储是进程内内存实现,进程一停全丢。BasicApp 用 Replace 换成 SqlSugar 落库:
| 框架接口 | BasicApp 实现 | 表 |
|---|---|---|
IWorkflowDefinitionStore | SqlSugarWorkflowDefinitionStore | Sys_Workflow_Definition |
IWorkflowInstanceStore | SqlSugarWorkflowInstanceStore | Sys_Workflow_Instance + Sys_Workflow_Node_Instance |
IWorkflowBookmarkStore | SqlSugarWorkflowBookmarkStore | Sys_Workflow_Bookmark |
必须用 Replace
框架 AddXiHanWorkflow 已经 TryAddSingleton 了内存实现,你再 TryAdd 会被静默忽略——表现是「重启后流程全没了」而没有任何报错。
存储是单例、每次操作开独立作用域;但 SqlSugar 客户端解析会加入当前环境里的事务工作单元,所以业务在 [UnitOfWork(true)] 里发起流程时,引擎写入与业务写入同一个事务提交或回滚。
另有一张 Sys_Workflow_Task_Record 记下每次办理、转办、加签与抄送,供「我已审批」「抄送我的」与审批轨迹使用——引擎的书签在办理后即删除,不能用来回看。
② 组织架构受理人规则
人工任务的受理人按规则 { type, values } 解析(规则机制与内置的 User / Starter / Expression 见框架文档)。BasicApp 基于 Saas 组织数据注册了六种:
| 规则 | 参数 | 解析为 |
|---|---|---|
Role | 角色编码列表 | 启用角色里有效期内的成员 |
Department | 部门编码列表 | 启用部门的有效成员(不含下级部门) |
DepartmentLeader | 部门编码列表 | 部门负责人 |
Position | 岗位编码列表 | 部门任职上挂这些岗位的用户 |
StarterLeader | 层级 N(缺省 1) | 发起人的第 N 级主管 |
StarterLeaderChain | 最多层级 N(缺省 1) | 发起人往上逐级的主管,配合「依次审批」做连续多级 |
角色、部门、岗位按编码引用(编码创建后不可改),同一份流程定义可以在租户间复用,业务的默认流程模板也能直接写系统角色编码。主管从发起人的主部门起沿上级部门逐级找负责人;发起人自己是某部门负责人时跳过该部门。
节点上与规则配套的设置:
| 设置 | 作用 |
|---|---|
跳过发起人本人 ExcludeStarter | 解析结果里去掉发起人,避免「自己批自己」 |
找不到受理人时 EmptyAssigneeStrategy | Fault 流程报错(缺省)/ AutoApprove / AutoReject / Fallback 转交兜底受理人 |
兜底受理人 FallbackAssigneeRules | 策略为 Fallback 时使用 |
抄送人 CcRules | 任务创建时抄送,进「抄送我的」,不参与办理 |
完成策略 CompletionPolicy | 或签 / 会签 / 依次审批 |
设计器的属性面板按这些规则给出选择器:角色、部门、岗位从组织目录选,用户远程检索,主管填层级,表达式提示可用的流程变量。旧版定义里的 Assignees / AssigneesExpression / CcUserIds 打开时会并入规则。
③ 审批业务接入
这是把「报销」「权限申请」这类业务单据挂到流程上的机制,详见下文 审批业务接入。
④ 权限与菜单
权限码是独立命名空间 workflow:*(不是 saas:*):
| 权限码 | 用途 |
|---|---|
workflow:read | 查看定义 / 实例;也可以查看任意审批的详情 |
workflow:create | 新建定义 |
workflow:update | 编辑草稿、发布、新版本、停用、归档;挂起 / 恢复实例 |
workflow:delete | 删除定义 |
workflow:execute | 发起实例、干预实例(取消/终止/重试) |
种子链 Order 300–304,顺序恒为「操作 300 → 资源 301 → 权限 302 → 菜单 303 → 角色授权 304」,默认仅授超管。
菜单由模块自己的 PageRegistry 驱动:
| 页面 | 路由 | 权限 |
|---|---|---|
| 审批中心 | /approval/center(挂在「审批」目录下) | 无(主体服务端锁定为当前用户) |
| 流程定义 | /workflow/definition | workflow:read |
| 流程实例 | /workflow/instance | workflow:read |
⑤ 待办通知与抄送记录
四个本地事件处理器,经 AddWorkflowLocalEventHandler<T> 登记进 XiHanLocalEventBusOptions.Handlers:
- 待办创建 → 站内通知受理人
- 待办转办 → 通知新受理人
- 实例故障 → 通知相关人
- 任务抄送 → 写抄送记录
裸 AddTransient 不会被订阅
本地事件总线只自动发现「以接口为服务类型」的注册。加新处理器必须走 AddWorkflowLocalEventHandler<T>(),否则静默不触发。
三个页面能做什么
审批中心(/approval/center)
四个页签,主体都由服务端锁定为当前用户,所以页面不需要权限码:
| 页签 | 内容 |
|---|---|
| 待我审批 | 当前用户的待办;「办理」打开详情抽屉 |
| 我已审批 | 当前用户办理、转办、加签过的记录,以及流程现在的状态与结果 |
| 我发起的 | 当前用户发起的流程,停在哪个节点、等谁审批;可按状态筛选 |
| 抄送我的 | 抄送给当前用户的流程 |
详情抽屉自上而下是:概要(状态、结果、业务、发起人)、业务单据(由审批业务登记的视图组件渲染)、办理区(仅待办)、撤回(仅发起人且流程进行中)、审批轨迹(发起 → 每次办理 / 转办 / 加签 / 抄送 / 自动处理 → 当前等待谁 → 流程结束)。
| 动作 | 说明 |
|---|---|
| 办理 | 同意 / 驳回,或节点白名单里的自定义结果;可带意见。单据组件暴露 getVariables() 时,返回值作为附加变量一起提交 |
| 转办 | 把任务移交给另一位用户(转办后原受理人的办理会被拒绝) |
| 加签 | 追加受理人 |
| 撤回 | 发起人撤回进行中(含故障)的流程,业务单据回到撤回状态 |
办理后若后续节点或业务回写出错,审批已经记录,页面提示流程进入故障态,由管理员在流程实例页重试。
查看详情的人须是发起人、受理人、办理或抄送过的人,或拥有 workflow:read;业务单据数据随之下发,业务侧不必再给审批人开单据的查看权限。
地址栏带 ?instanceId= 会直接打开该流程的详情(业务页面的「查看审批」就是这样跳过来的),?tab=done 等预选页签(待办通知里的链接用它)。这两个参数只用一次:页面读完就把地址换回 /approval/center,带参数的标签并入审批中心的标签,切回来或刷新不会再弹出详情。
流程定义(/workflow/definition)
定义的生命周期是一条状态链:
Draft 草稿 ──发布──► Published 已发布 ──停用──► Disabled 已停用 ──► Archived 已归档
▲ │
└──── 新版本 ◄─────────┘| 操作 | 应用服务方法 | 约束 |
|---|---|---|
| 新建 | CreateAsync | 建出来是 Draft |
| 编辑 | UpdateDraftAsync | 只能改草稿,已发布的不能直接改 |
| 发布 | PublishAsync | 只有已发布的定义才能启动实例;编码等于审批业务编码时,每个结束节点都要声明该业务允许的结果,且不能有终止节点 |
| 新版本 | NewVersionAsync | 基于现有定义开一个新版本草稿 |
| 停用 | DisableAsync | 不可启动新实例,存量实例继续运行 |
| 归档 | ArchiveAsync | 彻底下架 |
列表的「关联业务」列标出编码与哪个审批业务对应。设计器里什么都不选时,属性面板的「编码」一行可以先选「审批业务」:选中后流程编码随之改成业务编码,并补上业务写入的流程变量声明;问题列表提前提示缺结果的结束节点与终止节点(发布时后端同样拒绝)。编辑已有草稿时编码与业务只读,更新草稿沿用原编码。
流程实例(/workflow/instance)
分页查询 + 详情(含变量、执行历史与待恢复等待点),以及干预动作:
| 动作 | 语义 |
|---|---|
发起 StartAsync | 当前用户为发起人;同步链路能走多远走多远,可能一次调用就直接完成 |
取消 CancelAsync | 删书签、取消挂起节点;定义启用补偿时按执行逆序补偿 |
终止 TerminateAsync | 强制结束,不执行补偿、不可恢复 |
重试 RetryAsync | 从故障节点重新执行 |
取消与终止在实例已处于终态时幂等返回;故障实例也可以取消或终止(不必先重试成功)。审批业务的实例被取消或终止后,会回调业务把单据置为中止。
实例状态:Running(含等待书签的空闲态)/ Suspended / Completed / Canceled / Faulted(可重试)/ Terminated。经结果结束节点完成的实例另有「结果」(如 approved / rejected),列表与详情都会显示。
审批业务接入
业务单据(报销、合同、权限申请……)接审批流,只需要写一个处理器,不用碰引擎。约定只有两条:
- 业务编码 = 流程定义编码。发起时按编码取当前租户最新已发布的版本;管理员在设计器里改流程、发新版本,业务代码不用动。
- 单据主键 = 实例的
CorrelationId。按单据反查流程、批量查进度都靠它。
业务提交([UnitOfWork(true)])
│ 落业务表(状态 = 待审批)
│ IWorkflowBusinessService.StartAsync(业务编码, 单据主键, 标题, 变量)
▼ ← 引擎写入与业务写入同一事务
UserTask:按受理人规则解析审批人 → 待办 + 站内通知
│ 审批人在审批中心办理
│ └─ OnTaskCompletingAsync:办理前校验(如「不能审批自己的申请」),抛异常即拒绝办理
▼
走到结果结束节点(Outcome = approved / rejected)
│ └─ OnOutcomeAsync:回写业务表、授予权限……失败则回滚,实例进入故障态待重试
▼
实例 Completed(结果 = approved / rejected)1. 实现处理器
处理器接口 IWorkflowBusinessHandler 在 Saas 里(业务模块不依赖工作流模块):
public sealed class ExpenseWorkflowHandler : IWorkflowBusinessHandler
{
public WorkflowBusinessDescriptor Descriptor { get; } = new()
{
Code = "expense", // 同时是流程编码
Name = "费用报销",
FormComponent = "finance/expense/expense-approval", // 审批抽屉里渲染单据的前端组件
Variables = [new("amount", "报销金额", "number")], // 设计器据此提示分支条件
DefaultDefinition = BuildDefaultDefinition, // 可空:租户首次发起时自动建流程
};
// 办理前校验:只读,抛异常即拒绝本次办理
public Task OnTaskCompletingAsync(WorkflowBusinessTaskContext context, CancellationToken ct) => Task.CompletedTask;
// 流程得出结论:回写单据(必须幂等)
public async Task OnOutcomeAsync(WorkflowBusinessOutcomeContext context, CancellationToken ct)
{
// context.Outcome 为结束节点声明的 approved / rejected(或 Descriptor.Outcomes 里的自定义结果)
}
// 流程被审批中心撤回、或被管理员取消 / 终止:把单据置为中止(必须幂等)
public async Task OnAbortedAsync(WorkflowBusinessAbortedContext context, CancellationToken ct) { }
// 审批抽屉读取单据数据;单据不存在返回 null
public async Task<object?> GetFormDataAsync(string businessId, CancellationToken ct) => ...;
}在业务模块里注册:
services.TryAddEnumerable(ServiceDescriptor.Transient<IWorkflowBusinessHandler, ExpenseWorkflowHandler>());2. 在业务写侧发起与撤回
[UnitOfWork(true)]
public async Task<ExpenseDto> SubmitAsync(ExpenseSubmitDto input, CancellationToken ct)
{
var expense = await _expenseRepository.AddAsync(..., ct); // 状态 = 待审批
var result = await _workflowBusinessService.StartAsync(new WorkflowBusinessStartRequest
{
BusinessCode = "expense",
BusinessId = expense.BasicId.ToString(CultureInfo.InvariantCulture),
Title = $"费用报销:{expense.Title}",
Variables = { ["amount"] = expense.Amount },
}, ct);
expense.WorkflowInstanceId = result.InstanceId; // 单据记住实例,便于跳转审批中心
...
}| 方法 | 说明 |
|---|---|
StartAsync | 当前用户为发起人。业务未登记、同一单据已有进行中的审批、流程未发布、发起即故障(如首个节点解析不到审批人)都抛业务异常,事务随之回滚 |
WithdrawAsync | 业务自己撤回(删除单据、修改后重新提交前)。业务已自行更新单据状态,不再回调 OnAbortedAsync |
GetProgressAsync | 批量查单据的审批进度:状态、结果、停在哪个节点、等谁审批。列表页一次查一页 |
工作流模块没有加载时,IWorkflowBusinessService 的占位实现对每个调用都抛业务异常——业务不会在不知情的情况下跳过审批。
3. 回调的事务与上下文
| 回调 | 何时、在哪个上下文 | 失败时 |
|---|---|---|
OnTaskCompletingAsync | 审批人办理之前,审批人的请求上下文 | 拒绝本次办理,待办不变 |
OnOutcomeAsync | 结果结束节点执行时;有环境事务就加入,否则开独立工作单元 | 回滚回写,实例进入故障态;管理员重试会再次调用 |
OnAbortedAsync | 审批中心撤回、实例被取消 / 终止之后 | 记录日志,不影响已完成的中止 |
GetFormDataAsync | 查看审批详情时,按流程参与关系鉴权 | — |
OnOutcomeAsync 与 OnAbortedAsync 都可能被重复调用,必须幂等(例如按单据当前状态判断是否已处理)。
4. 默认流程模板
DefaultDefinition 让业务开箱即用:某租户第一次发起该业务、且租户里还没有任何同编码的流程定义时,按模板建一个并发布;之后它和手工设计的流程一样可改、可发新版本。租户里已有同编码定义(哪怕是草稿或已停用)就不再自动建——没发布则发起失败,尊重管理员的选择。
模板用框架的构建器写,结束节点用 AddOutcomeEnd 声明结果:
private static WorkflowDefinition BuildDefaultDefinition() =>
WorkflowDefinitionBuilder.Create("expense", "费用报销")
.AddStart()
.AddUserTask("leader-approve", "主管审批", node => node
.WithAssigneeRule(OrganizationAssigneeRuleTypes.StarterLeader, "1")
.WithProperty("ExcludeStarter", true))
.AddOutcomeEnd("end-approved", WorkflowUserTaskOutcomes.Approved, "通过")
.AddOutcomeEnd("end-rejected", WorkflowUserTaskOutcomes.Rejected, "驳回")
.AddTransition("start", "leader-approve")
.AddTransition("leader-approve", "end-approved", "outcome == 'approved'")
.AddTransition("leader-approve", "end-rejected", "outcome == 'rejected'")
.Build();5. 前端单据组件
FormComponent 是相对 src/views 的组件路径(与菜单 Component 同口径)。审批抽屉按路径加载它,传入:
| 属性 | 说明 |
|---|---|
businessId | 单据主键 |
data | GetFormDataAsync 的返回值 |
mode | approve(办理中)/ view(只读查看) |
nodeId | 当前待办节点(只读查看时为空) |
需要审批人补填字段时,组件 defineExpose({ getVariables }),办理时返回值作为流程变量一起提交。业务列表页可以用 GetProgressAsync 的结果显示审批进度,并以 /approval/center?instanceId=<实例主键> 跳到审批详情。
参照实现:权限申请
PermissionRequestWorkflowHandler(编码 permission-request)是第一个接入的业务:
- 提交申请即发起流程;删除、修改内容或改到终态时撤回进行中的审批,修改后重新发起;
- 默认模板是一个「管理员审批」节点,受理人为
tenant_owner与super_admin角色、跳过申请人本人; - 办理前拒绝审批自己的申请,并检查审批人能否授出该角色 / 权限;
- 通过时授予角色或权限并刷新授权缓存,驳回时只改申请状态;撤回或终止时申请置为已撤回。
演示流程
配置 Saas:Seed:EnableDemoData 为 true 时,种子在演示租户「示例企业」(demo-enterprise)里创建并发布四个流程,分类为「演示」;开关关着时不写。种子按流程编码先查后写,重复启动不重复创建,已有的不改、删掉的不补回;演示租户或演示账号(admin、manager、lead)不在时跳过并记日志。
| 编码 | 名称 | 变量 | 演示什么 |
|---|---|---|---|
demo-expense | 费用报销 | amount(必填)、reason | 直属主管或签(StarterLeader 1 级,跳过发起人,找不到主管时自动通过);条件分支:超过 10000 元再由总经理(Position gm)审批 |
demo-leave | 请假 | days(必填)、reason | 逐级主管依次审批(StarterLeaderChain 2 级);找不到审批人时转租户管理员(Fallback,兜底规则 Role tenant_admin);抄送人事专员;超过 3 天再由人事部(Department hr)审批 |
demo-procurement | 采购 | itemName、amount | 并行网关:财务会签(User 指定李管理、王主管,全部通过)与总部负责人审核(DepartmentLeader hq)同时进行,汇聚后总经理审批;总经理超时未办走超时分支,记一条警告日志后以 timeout 结局结束 |
demo-onboarding | 入职 | employeeName(必填)、travelAmount | 设置变量(设备清单、导师、按表达式算预算)、记日志;人事准备(Role hr_specialist,抄送发起人);导师确认(Expression 取变量里的导师周负责人,找不到时自动通过);等待;遍历设备清单,每件并行发起一个采购子流程;再以子流程发起一笔差旅报销 |
受理人规则引用的角色、部门、岗位都是示例企业演示数据里已有的编码,指定用户与导师在播种时按演示账号的用户名查出主键。用前端工程师赵工程师(engineer)发起费用报销,待办先到研发中心主管王主管,金额超过 10000 元再到总经理张总。
两个时长为演示缩短:
| 节点 | 时长 |
|---|---|
| 采购的「总经理审批」超时 | 120 秒 |
| 入职的「等待入职当天」 | 60 秒 |
超时与等待由定时器 Worker 恢复(XiHan:Workflow:Worker:IsTimerEnabled 缺省开启,每 5 秒轮询一次),实际触发会比设定晚几秒;关掉定时器时这两处不会自动推进。
以下节点没有放进演示流程:
Http、Script、PublishEvent:效果在流程之外(调外部地址、跑脚本、发事件给订阅方),演示环境没有对应的服务与订阅方,要么请求失败,要么页面上看不出结果;WaitSignal:等的是业务系统发来的信号,演示环境没有发信号的业务方,只能在流程实例页手动「发布信号」,看不出它的用途;Terminate、Fault:一个强行终止流程、一个让流程报错,放进演示就是一条注定中途失败的流程。
这些节点在设计器和代码方式里都能用,机制见框架 · Workflow。
运维注意
| 项 | 说明 |
|---|---|
| 定时器开关 | XiHan:Workflow:Worker:IsTimerEnabled 关掉后,延时 / 重试 / 节点超时书签永不自动恢复,只有人工任务与信号还能推进流程 |
| 集群单活 | 定时器 Worker 靠分布式锁保证集群内单活;分布式锁来自 Caching,未接 Redis 时会退化为进程内锁,多实例会各跑各的 |
| 环路防护 | 单批次最大节点执行数(默认 1000)、子流程最大嵌套深度(默认 16)都有硬上限,坏定义不会把进程转死 |
| 并发 | 引擎对同一实例的所有操作以分布式锁保证单写者;抢锁超时会抛 WorkflowLockTimeoutException |
| 业务回写失败 | 实例停在 Faulted,审批记录已保留;修好原因后在流程实例页重试,OnOutcomeAsync 会再次执行 |
相关页面
- 框架 · Workflow:引擎机制、内置活动、受理人规则、结果结束节点、代码方式定义流程
- 框架 · Workflow.Abstractions:定义模型与存储端口
- 审批与约束:审批中心与 RBAC 约束规则引擎的分工
- 权限模型:权限申请与委托
- 数据模型:工作流的表
- 消息中心:待办通知的投递通道
