审批是 2.3 新增的重要能力。这篇文章不讨论"要不要上工作流引擎",只讲一件事:用状态机 + 审批记录表,把固定 2~3 级审批做扎实,以及怎么在半小时内把它接进一个已经有了 AdminTable 的模块。
一、先划清边界
这个模块的定位在源码注释里写得很清楚:
轻量多级审批:状态机(状态存放在业务表)+ 审批记录表(流水 + 待办索引)。
能做的:
- 固定串联的 1N 级审批(常见 23 级)
- 审批人来源:部门负责人 / 角色 / 指定用户 / 单据字段
- 或签(同一节点多人,任一人处理即通过)
- 提交、同意、驳回、撤回、转交
- 待办 / 已办 / 我的申请 / 全部
- 轮次与时间线、变更重审、通过后锁定
不做(源码里也明说了): 会签、并行、条件分支、回退到任意节点。这些属于工作流引擎(如 Elsa)的范畴,硬套状态机只会得到一个越来越难维护的 switch。
二、状态与动作
1. 五个状态
public enum ApprovalStatus
{
Draft = 0, // 草稿(未提交审批)
Pending = 1, // 审批中
Approved = 2, // 已通过
Rejected = 3, // 已驳回
Revoked = 4 // 已撤回
}
关键在于:"第几级"不放在状态里,而是单独一个 CurrentLevel 字段:
public interface IApprovalBill
{
/// <summary>审批状态</summary>
ApprovalStatus ApprovalStatus { get; set; }
/// <summary>当前待审级次:0=未提交,1/2/3=第几级</summary>
int CurrentLevel { get; set; }
}
这样状态只有 5 个(可枚举、可穷举测试),级次可以任意扩展。
2. 五个动作
public enum ApprovalAction
{
Submit, // 提交
Approve, // 同意
Reject, // 驳回
Revoke, // 撤回
Transfer // 转交
}
状态机是纯函数,不碰数据库,所以能穷举单测:
public static (ApprovalStatus Status, int Level) Next(ApprovalAction action, int currentLevel, int maxLevel)
{
if (maxLevel < 1) throw new ArgumentOutOfRangeException(nameof(maxLevel), "审批流程至少需要一级");
return action switch
{
ApprovalAction.Submit => (ApprovalStatus.Pending, 1),
ApprovalAction.Approve when currentLevel < maxLevel => (ApprovalStatus.Pending, currentLevel + 1),
ApprovalAction.Approve => (ApprovalStatus.Approved, 0),
ApprovalAction.Reject => (ApprovalStatus.Rejected, 0),
ApprovalAction.Revoke => (ApprovalStatus.Revoked, 0),
ApprovalAction.Transfer => (ApprovalStatus.Pending, currentLevel),
_ => throw new InvalidOperationException($"未知的审批动作:{action}")
};
}
把状态流转画出来:
草稿 ──提交──> 一级审批中 ──同意──> 二级审批中 ──同意──> 已通过
│ │
├──驳回───────────────┴──> 已驳回 ──修改后提交──> 一级(新一轮)
└──撤回(无人审批前)───> 已撤回 ──提交──> 一级(新一轮)
审批中 ──转交──> 级次不变,只换审批人
三、五步接入
第一步:安装并注册
dotnet add package EasyAdminBlazor.Approval
builder.AddEasyAdminBlazor(new EasyAdminBlazorOptions { ... })
.AddEasyAdminBlazorApproval(o =>
{
o.Flows.Add(new ApprovalFlowConfig
{
BillType = typeof(Article).FullName!,
Levels =
[
new ApprovalLevelConfig { Level = 1, Name = "部门主管审批", Kind = ApproverKind.OrgLeader, Offset = 1 },
new ApprovalLevelConfig { Level = 2, Name = "管理员审批", Kind = ApproverKind.Role, Value = "Administrator" }
]
});
});
注册做了什么(ServiceCollectionExtensions.cs):
| 注册项 | 说明 |
|---|---|
IApprovalFlowProvider → SysConfigApprovalFlowProvider |
流程配置来源:代码注册 or 后台参数配置 |
IApproverResolver → DefaultApproverResolver |
审批人解析 |
IApprovalUserProvider → AdminApprovalUserProvider |
当前用户 / 权限 / 组织 |
IApprovalNotifier → AdminMessageApprovalNotifier |
站内信 + SignalR 通知 |
IApprovalGateway → ApprovalService |
替换核心包的 NullApprovalGateway |
ApprovalInitializer(HostedService) |
启动时建表、修复历史轮次 |
这张表里最重要的一行是最后一行:核心包里的 AdminTable 只依赖 IApprovalGateway 接口(未安装扩展时是空实现),所以审批是可选扩展,不装也不会影响其他功能。
流程也可以放到后台"参数配置"里,Code 为 APPROVAL_FLOW_{单据全名},值是流程 JSON——改了不用重启。
第二步:业务实体接入审批
把基类换掉即可,审批字段由基类提供:
// 需要软删除
public partial class Article : ApprovalEntityFull
{
// 原有字段不动
}
// 不需要软删除
public partial class Order : ApprovalEntity { }
两个基类的唯一区别是继承链(EntityFull / EntityCreated),字段完全一致:
public abstract class ApprovalEntityFull : EntityFull, IApprovalBill
{
[Column(Position = -8)]
[DisplayName("审批状态")]
public virtual ApprovalStatus ApprovalStatus { get; set; } = ApprovalStatus.Draft;
[Column(Position = -7)]
[DisplayName("审批级次")]
public virtual int CurrentLevel { get; set; }
/// <summary>列表展示用,不落库</summary>
[Column(IsIgnore = true)]
[DisplayName("审批状态")]
public string ApprovalStatusText => ApprovalStatus switch
{
ApprovalStatus.Draft => "草稿",
ApprovalStatus.Pending => CurrentLevel > 0 ? $"{CurrentLevel} 级审批中" : "审批中",
ApprovalStatus.Approved => "已通过",
ApprovalStatus.Rejected => "已驳回",
ApprovalStatus.Revoked => "已撤回",
_ => string.Empty
};
}
ApprovalStatusText 是 [Column(IsIgnore = true)] 的展示属性——表格里直接绑它就能显示"2 级审批中"这种可读文案。
如果实体已经继承了别的基类,直接实现接口 + 手写两个字段也可以:
public class MyBill : EntityCreated, IApprovalBill
{
[Column(Position = -8)] public ApprovalStatus ApprovalStatus { get; set; }
[Column(Position = -7)] public int CurrentLevel { get; set; }
}
注意源码里的提醒:接口只提供契约,字段仍需由实体或基类声明,否则 ORM 不会建列。发起人和提交时间复用 IEntityCreated 的 CreatedUserId / CreatedTime,不需要额外字段。
第三步:页面接入
方式一:AdminTable 自动接管。
实体实现 IApprovalBill 后,AdminTable 会自动识别:
// 实体实现 IApprovalBill 时自动接管审批(未安装 Approval 扩展时空实现直接跳过)
_autoApproval = typeof(IApprovalBill).IsAssignableFrom(typeof(TItem));
自动做的事:
- 保存后按配置决定是否自动提交审批;
- 审批中的单据禁止编辑、删除;
- 如果你在列里绑了
ApprovalStatus/ 状态文本,就能显示审批状态。
方式二:审批面板组件。
更常见也更直观的做法是在编辑模板里加一个"审批"选项卡:
@inject CommonLocalizer CommonLocalizer
<Tab IsCard>
<TabItem Text="@CommonLocalizer["随笔"]">
<!-- 业务字段 -->
</TabItem>
<TabItem Text="@CommonLocalizer["审批"]">
<ApprovalActions TItem="Article" Bill="Model" OnChanged="OnApprovalChanged" />
</TabItem>
</Tab>
@code {
[Parameter][NotNull] public Article? Model { get; set; }
[Parameter] public EventCallback OnApprovalChanged { get; set; }
}
ApprovalActions 组件内部(Components/ApprovalActions.razor)会:
- 显示当前审批状态徽章;
- 按
CanSubmitAsync/CanCurrentUserApproveAsync决定显示哪些按钮; - 提供审批意见输入框;
- 显示审批轨迹(
ShowHistory,默认 true); - 操作成功后把最新状态同步回传入的单据对象,再触发
OnChanged让宿主刷新列表。
最后那一步有个容易踩的坑,源码里专门写了注释:
/// <summary>
/// 把最新的审批状态同步回传进来的单据对象。
/// 服务改的是数据库,而列表/表单持有的是同一个实体实例,不同步的话界面上会一直显示旧状态。
/// </summary>
private async Task SyncBillStateAsync()
{
if (Bill is null) return;
var view = await ApprovalGateway.GetBillViewAsync(BillType, Bill.Id);
if (view?.Status is null) return;
Bill.ApprovalStatus = view.Status.Value;
Bill.CurrentLevel = view.CurrentLevel;
}
宿主页面再加一个状态列,并让 OnChanged 刷新表格:
<TableColumn @bind-Field="context.ApprovalStatus" Filterable="true">
<Template Context="v">@v.Row.ApprovalStatusText</Template>
</TableColumn>
...
<EditTemplate>
<ArticleEditTemplate Model="context" OnApprovalChanged="ReloadAsync" />
</EditTemplate>
@code {
private AdminTable<Article, long> _table = default!;
private async Task ReloadAsync()
{
if (_table is not null) await _table.Reload();
}
}
第四步:配好审批人
四种审批人来源:
public enum ApproverKind
{
[Display(Name = "部门负责人")] OrgLeader = 0, // 按 SysOrg.ResponsibleUserId 逐级向上查找
[Display(Name = "角色")] Role = 1, // 该角色下全部有效用户,或签
[Display(Name = "指定用户")] User = 2,
[Display(Name = "单据字段")] FormField = 3 // 字段值作为审批人 Id
}
解析规则(DefaultApproverResolver):
| 来源 | 配置 | 行为 |
|---|---|---|
| 部门负责人 | Offset = 1 |
从发起人所在组织开始向上找第 N 个"负责人";中间层缺负责人自动继续向上 |
| 角色 | Value = "财务" |
取该角色下所有 IsEnabled != false 的用户,或签 |
| 指定用户 | Value = "10086" |
用户必须存在且未停用,否则视为无人可审 |
| 单据字段 | Field = "AuditorId" |
支持 long / long? / long[] / 可解析为 long 的字符串 |
两个容易被忽略的细节:
(1)默认禁止自审。
/// <summary>是否允许"自己审自己"(提交人成为自己单据的审批人),默认 false。</summary>
public bool AllowSelfApproval { get; set; }
默认不允许时:部门负责人这一级会继续向上找(单人部门很常见);角色 / 单据字段会把提交人从名单里排除。排除后没人可审,提交时就会提示"未找到「XX」的审批人,请检查审批流程配置"。
要允许自审(比如单人部门负责人就是唯一员工),显式打开:
.AddEasyAdminBlazorApproval(o => { o.AllowSelfApproval = true; ... })
(2)审批人在提交时一次性解析并落库。
// 审批人在提交时一次性解析并落库(固定 2~3 级流程),
// 这样"我的待办"只需查审批记录一张表,不用遍历各业务表。
这是设计上的关键取舍:解析结果存进 sys_approval_record,待办查询就变成"查一张表 + 两个索引",不需要为了显示待办去 union 所有业务表。
第五步:跑通流程
把上面的配好之后:
- 用户新增一条 Article → 状态是
Draft(草稿,保存与提交分离); - 在"审批"选项卡点提交审批 → 状态变
Pending,CurrentLevel = 1; - 一级审批人收到站内信"待您审批:xxx",点进去到审批中心;
- 审批人点同意 → 若还有下一级则
CurrentLevel + 1,否则Approved; - 期间发起人可以在无人审批前撤回;审批人可以转交给别人;
- 被驳回后修改再提交,会进入新一轮(Round + 1),旧轮次记录完整保留。
四、sys_approval_record:一张表干三件事
审批记录表的表结构注释写得非常清楚:
一张表同时承担三个角色:
1) 审批流水:按单据查询即为完整审批轨迹(Timeline 展示);
2) 待办索引:IsCurrent and Status=Pending and ApproverUserId=我 即"我的待办",无需跨业务表查询;
3) 通知目标:提交/过级时按本表审批人推送站内信。
对应两个索引:
[Index("idx_approval_record_bill", "BillType,BillId", false)]
[Index("idx_approval_record_todo", "ApproverUserId,IsCurrent", false)]
几个语义要点:
| 字段 | 含义 |
|---|---|
Level |
0 = 发起记录,1..N = 审批节点 |
Round |
轮次;驳回/撤回后重新发起会 +1 |
IsCurrent |
是否当前待处理节点(待办查询的过滤条件) |
Status |
节点结果:Pending / Submitted / Approved / Rejected / Revoked / Transferred / Skipped |
ApproverUserId |
"该节点应由谁处理" |
OperatorUserId |
"实际是谁操作的"(转交、代审时两者不同) |
BeforeStatus / AfterStatus |
审计用的状态变更前后快照 |
InstanceKey |
{BillType}#{BillId}#{Round},把实例、节点、历史关联起来 |
Skipped 状态是"或签"场景的产物:A 先同意了,同节点其他待处理记录会被置为 Skipped,避免节点被完成两次。
五、待办、通知与审批中心
1. 审批中心页面
框架内置了 /Admin/ApprovalTodo,支持四种视角:
public enum ApprovalListScope
{
Todo = 0, // 待我处理
Done = 1, // 我处理过的(已办)
Submitted = 2, // 我发起的(我的申请)
All = 3 // 全部(仅管理员)
}
菜单和按钮由 ApprovalMenuProvisioner 自动补齐,且是幂等的:
1) 没有审批菜单就创建(一级菜单「审批中心」);
2) 老结构(审批中心分组 + 我的待办子菜单)升级成单一一级菜单;
3) 补齐 approve/reject/revoke 三个按钮权限。
这三步是旧项目能平滑升级的关键:框架的 SeedData 只在菜单表为空时插入,老项目装了扩展后菜单表非空,新菜单不会自动出现,所以由扩展在启动时补齐。
按钮权限同样要勾给角色——非管理员用户如果没有 approve / reject / revoke 权限,审批按钮不会显示,服务端也会拒绝。管理员自动放行。
2. 通知
默认通知走站内信(含 SignalR 实时推送):
public interface IApprovalNotifier
{
/// <summary>推送待办通知给当前节点审批人</summary>
Task NotifyPendingAsync(IReadOnlyCollection<SysApprovalRecord> records);
/// <summary>把审批结果通知发起人</summary>
Task NotifyResultAsync(SysApprovalRecord record, ApprovalStatus status);
}
待办通知的收件人计算有个值得学习的细节:
/// <summary>
/// 计算待办通知的收件人:只认审批节点——级次大于 0、状态为待处理、且有审批人。
/// 发起记录(Level=0)里同样存了 ApproverUserId,但那个值是提交人,
/// 不能被当成审批人,否则提交人就会收到本该发给审批人的待办通知。
/// </summary>
public static long[] ResolveRecipients(IEnumerable<SysApprovalRecord> records)
=> records
.Where(x => x.Level > 0
&& x.Status == ApprovalRecordStatus.Pending
&& x.ApproverUserId is > 0)
.Select(x => x.ApproverUserId!.Value)
.Distinct()
.ToArray();
通知里的跳转地址统一是 /Admin/ApprovalTodo,点击直接进审批中心。
还有一条原则:通知失败不影响审批流程本身(try/catch + 日志告警),并且通知只在事务提交之后发送——第 18 篇会展开。
六、策略配置速查
ApprovalOptions 里的全局开关(流程配置优先):
| 配置 | 默认 | 含义 |
|---|---|---|
Enabled |
true |
是否启用审批扩展 |
AutoCreateMenu |
true |
菜单缺失时自动创建审批中心 |
AllowSelfApproval |
false |
是否允许自己审自己 |
AutoSubmitOnSave |
false |
保存后是否自动提交(默认保存与提交分开) |
ResubmitOnEdit |
true |
已通过的单据被修改后是否重新走审批 |
AllowEditAfterApproved |
false |
已通过的单据是否还允许修改(默认通过后锁定) |
RequireCommentOnReject |
true |
驳回是否必须填意见 |
RequireCommentOnApprove |
false |
同意是否必须填意见 |
AllowRevoke |
true |
发起人能否在无人审批前撤回 |
EnableNotification |
true |
是否推送站内信通知 |
流程级可覆盖的:ResubmitOnEdit、AutoSubmitOnSave、AllowEditAfterApproved、ResubmitFields。
其中"变更重审"的判定方式值得单独提一句:提交时把 ResubmitFields 里各字段的值按字段名排序拼成文本做 SHA256,存进该轮发起记录的 ResubmitHash;再次保存时重新算一遍比对,一致就跳过审批。只存指纹不存原值,不会把业务数据抄一份进审批记录。
七、常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 提交时提示"未找到「XX」的审批人" | 审批人解析结果为空 | 检查组织负责人是否维护、角色下是否有有效用户;或打开 AllowSelfApproval |
| 非管理员看不到审批按钮 | 角色没勾 approve / reject / revoke |
审批中心菜单下勾按钮权限,或确认 ApprovalMenuProvisioner 已补齐 |
| 保存后没有进入审批 | 默认保存与提交分离 | 打开 AutoSubmitOnSave,或让用户点"提交审批" |
| 已通过的单据改不了 | 默认通过后锁定 | 按需打开 AllowEditAfterApproved |
| 改了单据却没有重审 | 关键字段没变(ResubmitFields 指纹一致) |
确认改的是关键字段,或调整 ResubmitFields |
| 待办列表看不到单据 | 扩展未安装 / 菜单未创建 | 确认 AddEasyAdminBlazorApproval 已注册且 AutoCreateMenu = true |
| 提交人收到了待办通知 | 早期版本的收件人计算问题 | 2.3 已修复为"只认审批节点(Level > 0)" |
八、小结
EasyAdminBlazor 2.3 的审批模块,核心就三样东西:
- 状态机(
ApprovalStateMachine)解决"怎么流转",纯函数、可穷举测试; - 审批记录表(
sys_approval_record)解决"轨迹、待办、通知"三件事,一张表 + 两个索引; - 流程配置(
ApprovalFlowConfig)解决"谁能审",四种来源 + 或签 + 禁止自审。
接入成本很低:装包、注册流程、换个实体基类、编辑模板加一个"审批"选项卡。剩下的复杂部分(并发、事务、通知时序)框架已经处理好了——那正是后面两篇要讲的内容。
如果你的 .NET 10 + Blazor 后台需要多级审批,又不想引入完整工作流引擎,EasyAdminBlazor 的轻量审批可以先用起来:状态存在业务表上,审批记录独立成表,扩展和降级都容易。