跳到主要内容

流程设计

节点类型​

节点类型常量Wire value说明
开始NodeStartstart工作流入口
审批NodeApprovalapproval需要审批人执行审批动作
办理NodeHandlehandle需要处理人执行办理动作
条件NodeConditioncondition基于条件进行分支
抄送NodeCCcc向指定用户发送通知
结束NodeEndend工作流终点

条件分支​

条件节点会按 priority 顺序评估 ConditionBranch。每个分支包含一个或多个 ConditionGroup:同一个 group 内的条件按 AND 组合,同一分支上的多个 group 按 OR 组合。

ConditionField 使用结构化的 Subject / Operator / Value 字段。 Operator 类型是 ConditionOperator;公开常量包括 OperatorEquals、 OperatorNotEquals、OperatorGreater、OperatorGreaterOrEq、OperatorLess、 OperatorLessOrEq、OperatorIn、OperatorNotIn、OperatorContains、 OperatorNotContains、OperatorStartsWith、OperatorEndsWith、 OperatorIsEmpty 和 OperatorIsNotEmpty。内置 evaluator 以原生 Go 比较 求值 field condition——不做表达式模板化、没有注入面——操作符语义按字段类型 定型;词汇表之外的操作符会以错误终止求值,而不是静默评为 false。

ConditionExpression 执行原始 Expression 字符串。 评估环境暴露:

名称值
formData当前实例的 FormData map
applicantId当前申请人 ID
applicantDepartmentId申请人部门 ID;不存在时是 ""
globals宿主解析出的 Instance.Globals,作为顶层 binding 暴露

表达式条件经框架的 expression.Engine 抽象求值(当前由 expr-lang 支撑),由 DI 装配——即表达式引擎文档描述的 同一引擎。

宿主应用可以实现 approval.InstanceGlobalsResolver,在实例启动时根据已认证 principal 解析全局变量。该快照会持久化到 Instance.Globals;客户端不能在 start 请求体里提交它。Field condition 会先从 globals 解析 Subject,再查 formData;expression condition 会把 globals 暴露为顶层 binding,但内置的 formData、applicantId、applicantDepartmentId 名称发生冲突时优先级更高。

明细表格聚合​

字段条件可以不比较标量 subject,而是对明细表格的行做聚合。条件仍然 是结构化的——没有字符串 DSL:subject 指定表格字段,aggregate 选择聚合 方式,column 指定要聚合的数字列。

AggregateKindWire valuecolumn聚合结果
AggregateSumsum必填指定数字列的求和
AggregateCountcount禁止填写行数
AggregateAvgavg必填指定数字列的平均值

AggregateKind.FoldsColumn() 报告某个 kind 聚合的是列(sum / avg) 还是行(count)。聚合本身通过 approval.Aggregator 接口可插拔:

type Aggregator interface {
// Kind 返回该实现所聚合的 aggregate kind。
Kind() AggregateKind
// Fold 把提取出的列值(或行数)归约成比较操作数。
// matchable=false 表示该聚合对输入没有定义值——例如对零行求 avg——
// 此时条件必须不匹配,与 SQL NULL 比较语义一致。
Fold(values []float64, rowCount int) (result float64, matchable bool)
}

用 vef.ProvideApprovalAggregator 在内置 sum / count / avg 之外注册 自定义聚合器;条件 evaluator 按其 AggregateKind 自动拾取,无需改动已有代码:

vef.Run(
vef.ApprovalModule,
vef.ProvideApprovalAggregator(func() approval.Aggregator { return myMedian{} }),
app.Module,
)

审批方式​

当节点有多个审批人时:

方式常量Wire value行为
顺序ApprovalSequentialsequential审批人按顺序逐个处理
并行ApprovalParallelparallel审批人同时处理

枚举类型是 ApprovalMethod。

通过规则​

规则常量Wire value行为
全部PassAllall所有审批人必须同意
任意PassAnyany至少一人同意即通过
比例PassRatioratio达到一定比例即通过

自定义通过规则实现使用 PassRuleStrategy、PassRuleContext,并返回 PassRuleResult(PassRulePending、PassRulePassed、PassRuleRejected)。

审批人类型​

类型常量Wire value说明
指定用户AssigneeUseruser特定用户
角色AssigneeRolerole拥有某角色的用户
部门AssigneeDepartmentdepartment所配置部门的负责人
申请人本人AssigneeSelfself申请人自己
直接上级AssigneeSuperiorsuperior直接上级
部门领导链AssigneeDepartmentLeaderdepartment_leader申请人所在部门的负责人(单层查询)
表单字段AssigneeFormFieldform_field由表单字段值决定

枚举类型是 AssigneeKind。动态加签位置使用 AddAssigneeType: AddAssigneeBefore(before)、AddAssigneeAfter(after)、 AddAssigneeParallel(parallel)。

节点字段权限​

任务节点(TaskNodeData,被审批和办理节点嵌入)和抄送节点(CCNodeData) 携带 fieldPermissions map:表单字段 key → Permission。取值如下:

常量Wire value对该节点参与者的含义
PermissionVisiblevisible只读
PermissionEditableeditable可以提交新值
PermissionHiddenhidden不展示
PermissionRequiredrequired可编辑且必须提供

缺失的 key 视为 visible。部署校验会对照派生的表单字段检查这个 map:每个 key 必须引用一个顶层表单字段,取值必须在枚举内,抄送节点只能使用 visible / hidden 子集,并且当节点的超时动作解析为 auto_pass 时会拒绝 required 权限(超时扫描器的 auto-pass 结单时不会执行必填检查)。

该 map 在写路径上强制生效:任务处理时,提交的 formData 只会合并 fieldPermissions 中标为 editable 或 required 的字段,且合并的子集会 再按字段定义重新校验。required 的必填检查只在 approve 和 handle 决策时执行——reject、transfer、rollback 保持豁免。没有配置 fieldPermissions 的节点不授予任何写权限:提交的表单数据会被丢弃。

在读侧,my.get_instance_detail 返回按查看者投影的 fieldPermissions: 框架在查看者的参与上下文(本人任务、抄送、申请人)上对格 (hidden < visible < editable < required)做 max-merge,只有 Pending 任务或可重新提交的申请人才能获得写强度(editable / required),所有 只读上下文都被钳制为 visible。hidden 的值会从返回的 formData 中剥离, 没有任何可识别上下文的查看者什么都看不到——解析是 fail-closed 的。

设计器默认值​

NodeData.ApplyTo 会把省略的设计器字段解析为导出的默认值,保证运行时和未触碰过的设计器控件一致:

常量值
DefaultExecutionTypeExecutionManual
DefaultApprovalMethodApprovalParallel
DefaultPassRulePassAll
DefaultEmptyAssigneeActionEmptyAssigneeAutoPass
DefaultSameApplicantActionSameApplicantSelfApprove
DefaultConsecutiveApproverActionConsecutiveApproverNone
DefaultRollbackTypeRollbackPrevious
DefaultRollbackDataStrategyRollbackDataKeep
DefaultTimeoutActionTimeoutActionNone
DefaultCCTimingCCTimingAlways
DefaultHandleApprovalMethodApprovalSequential
DefaultHandlePassRulePassAny
DefaultUrgeCooldownMinutes30

流程定义(兼容 React Flow)​

FlowDefinition 结构体兼容 React Flow 的 JSON 格式:

type FlowDefinition struct {
Nodes []NodeDefinition `json:"nodes"`
Edges []EdgeDefinition `json:"edges"`
}

每个 NodeDefinition 包含 Kind 和强类型的 Data 字段,框架会按 Kind 把 Data 解析成对应结构(StartNodeData、ApprovalNodeData、HandleNodeData、ConditionNodeData、CCNodeData、EndNodeData)。

流程 JSON Wire Shape​

deploy 会把流程定义当作完整快照处理。NodeDefinition.ParseData 根据 kind 选择对应的强类型 data 结构;未知 kind 返回 ErrUnknownNodeKind, 节点 data 的 JSON 解析失败会用 ErrNodeDataUnmarshal 包装。

类型JSON 字段
FlowDefinitionnodes、edges
NodeDefinitionid、kind、position、data;position 包含 x 和 y
EdgeDefinitionid、source、target、sourceHandle、data

只有条件节点的出边需要 sourceHandle,且它必须匹配某个分支 id。非条件节点的出边必须省略 sourceHandle。EdgeDefinition.data 是设计器元数据,保存在版本 flowSchema 中;运行时流转使用 source、target 和 sourceHandle。

节点 data 字段如下:

节点 data 类型JSON 字段
BaseNodeDataname、description;每种节点 data 都嵌入它
StartNodeData只有 base 字段
EndNodeData只有 base 字段
TaskNodeDataassignees、executionType、emptyAssigneeAction、fallbackUserIds、adminUserIds、isTransferAllowed、isOpinionRequired、timeoutHours、timeoutAction、timeoutNotifyBeforeHours、urgeCooldownMinutes、ccs、fieldPermissions
ApprovalNodeDatabase 字段 + TaskNodeData 字段 + approvalMethod、passRule、passRatio、sameApplicantAction、consecutiveApproverAction、rollbackType、rollbackDataStrategy、rollbackTargetKeys、isRollbackAllowed、isAddAssigneeAllowed、addAssigneeTypes、isRemoveAssigneeAllowed、isManualCcAllowed
HandleNodeDatabase 字段 + TaskNodeData 字段;部署时无条件设置 approvalMethod = sequential、passRule = any(handle 节点没有这两个 wire 字段)
CCNodeDatabase 字段 + ccs、isReadConfirmRequired、fieldPermissions
ConditionNodeDatabase 字段 + branches

assignees 条目使用 kind、ids、formField 和 sortOrder。ccs 条目使用 kind、ids、formField 和 timing。部署时这些嵌入数组会额外物化为 FlowNodeAssignee 和 FlowNodeCC 记录,不只是写入 FlowNode 行。

条件分支使用 id、label、conditionGroups、isDefault 和 priority。 每个 conditionGroups 条目包含 conditions;每个 condition 使用 kind、 subject、operator、value 和 expression。字段条件也可以改为对明细表格的行做聚合: aggregate(sum / count / avg)对 subject 指定的表格字段求值, column 指定要聚合的数字列(sum / avg 必填,count 禁止填写)——见 明细表格聚合。

timeoutHours 和 timeoutNotifyBeforeHours 的单位是小时。 urgeCooldownMinutes 的单位是分钟;小于等于 0 时使用 30 分钟运行时默认值。rollbackTargetKeys 只在 rollbackType = specified 时校验,里面放的是节点 key,不是数据库节点 ID。 fieldPermissions 的语义见节点字段权限。

表单 Schema 与派生字段​

表单定义在部署时一分为二:

  • FlowVersion.FormSchema(formSchema)是宿主自有的表单设计器文档, 在 deploy 时以 params.formSchema 提交,以语义等价的 JSON 存储和返回—— jsonb 列会规范化格式与键序,数字精度端到端保留(全程 json.RawMessage)。框架从不解释它。
  • FlowVersion.FormFields(formFields)是部署时通过注入的 approval.FormSchemaParser 从该文档一次性派生出的扁平 []FormFieldDefinition 列表,是框架自身消费的唯一表单形状——用于 表单数据校验、storage-table DDL、聚合校验和字段权限解析。parser 升级 永远不影响已部署的版本。
type FormSchemaParser interface {
ParseFormFields(ctx context.Context, schema json.RawMessage) ([]FormFieldDefinition, error)
}

内置 parser 理解 vef-framework-react form-editor 文档;使用自有设计器的宿主 用 vef.ProvideApprovalFormSchemaParser(constructor) 整体替换。nil 或空 schema 产出零字段(没有表单的流程);parser 报错会中止部署。ctx 携带部署 请求的 deadline——执行 I/O 的宿主 parser 必须遵守它。

派生字段会在部署时校验(key 唯一、kind 已知、pattern 可编译、边界一致、 明细表单层、选项来源自洽)。每个 FormFieldDefinition 条目使用 key、kind、label、placeholder、defaultValue、isRequired、 options、optionSource、validation、props、sortOrder、columnType、 scale 和 columns。每个 option 使用 label 和 value。columns 定义 table 字段 (kind 为 table)的行结构:每一列本身也是一个 FormFieldDefinition,且不能再声明 自己的 columns——明细表格只能是单层。table 字段自身的 validation.minLength / maxLength 用于约束行数,isRequired 表示至少要有一行。

validation 支持 minLength、maxLength、min、max、pattern 和 message。提交的 formData 受 vef.approval.form_data_max_bytes 限制 (默认值 64 KiB,来自 config.DefaultFormDataMaxBytes),即使流程没有表单 schema 也会执行这个大小限制。有 schema 时,额外的表单 key 会被拒绝;必填字段会拒绝 缺失、null、空白字符串和空数组。input、textarea、date 字段必须是字符串, 可使用 minLength、maxLength 和 pattern。number 字段接受 JSON 数字, 可使用 min 和 max;columnType 为 integer 的 number 字段还会拒绝小数值。 select 字段在配置了 options 时会校验标量或数组值是否 存在于选项中。upload 字段接受非空白字符串、非空 []string,或非空且每项都是非空白 字符串的数组。validation.message 只作为 pattern 不匹配时的自定义错误信息; 其他校验失败使用模块 i18n 消息。

选项来源:枚举出来的,还是远程的​

选择类字段的选项只会以两种形状之一出现,绝不会同时出现:

  • options([]FieldOption)——部署时选项可以被枚举,投影就直接把它写出来。 静态来源无论是内联配置在字段上,还是通过表单全局的 ref 引用到的,都会被 枚举出来。
  • optionSource(*FieldOptionSource)——选项无法被枚举,投影于是输出 消费方自行拉取所需的描述符。只有远程来源会走到这个形状。

两者都没有的字段就是自由输入:没有任何东西约束它的取值。

投影结果是解引用之后的。设计器里的 ref 会先针对表单全局来源解析,再写入 字段,所以消费方永远不需要自己去追 dataSourceId。指向不存在来源的 ref 既不 产出 options 也不产出 optionSource——和完全没有配置来源的字段一样。

FieldOptionSource 的结构:

字段类型含义
kindOptionSourceKind来源分类;目前只会输出 OptionSourceRemote(remote)
request*RemoteOptionRequest返回选项记录的那个操作
mapping*RemoteOptionMapping如何从每条记录里读出 label 和 value;nil 表示用默认值

设计器自身的来源联合类型还有 static 和 ref,但两者都活不过投影,所以 OptionSourceKind 的词汇表刻意比设计器的更窄。

RemoteOptionRequest 用框架自己的 resource/action 寻址方式定位那个操作: resource、action、version(为空表示默认版本)和 params。 RemoteOptionMapping 指出记录里的键名——labelKey(默认 label)、 valueKey(默认 value)、disabledKey 和 descriptionKey;留空的条目回落到 各自的默认值。

框架从不解析选项来源:表单数据校验只读 options,而没有 options 的 select 字段接受任意提交值——所以 optionSource 在服务端不约束任何东西。它的 存在是为了那些必须把已存储的值渲染成 label 的消费方:业务列表页展示一个存下来 的 select 值时需要的是 label 而不是原始编码,而静态那一半早就由 options 覆盖了。

部署校验同样覆盖选项来源,顶层字段和明细表格列一视同仁:kind 不在词汇表内的 optionSource,或者 request 缺失、resource / action 为空白的远程来源,都会 让部署失败。这两种情况在别处都不会失败——框架从不解析来源——于是版本会干干净净地 部署上去,然后在每一个回放它的消费方那里渲染成原始值。

明细表格的列自带这一份:columns 里的条目本身就是完整的 FormFieldDefinition, 所以 table 字段内部的 select 列拥有和顶层字段一样的 options / optionSource 组合。

远程请求参数​

RemoteOptionRequest.Params 是 map[string]DynamicParam,且不求值地携带。 每个参数只会是两种 kind 之一:

DynamicParamKindWire value载荷含义
DynamicParamLiteralliteralvalue设计器填写的固定值,每次求值都一样
DynamicParamExpressionexpressionsource表单运行时在发起请求前针对当前表单值求值的表达式——级联选择就是靠它

后端手里没有表单值可以拿来对表达式求值,因此它只存源文本,求值这一步归回放请求的 消费方所有。value 刻意不带 omitempty:字面量的 false、0、"" 都是设计器 选定的值,丢掉它会静默改变请求。

翻译整列之前,先问 HasBoundParams()。 (*RemoteOptionRequest).HasBoundParams() 报告是否存在表达式参数。消费方在假定 "一次查询就能覆盖整列"之前,必须先问这个问题:

  • false——请求对每一行的解析结果都相同,所以一次调用就能为整列建好 value → label 映射。
  • true——选项集是按行的(参数依赖该行自己的表单值),列表页要么逐行求值 并发起请求,要么让这一列不翻译。

该方法对 nil 安全:nil receiver 返回 false。

在宿主里把存储值翻译成 label​

注入 approval.FormSchemaParser——只要启用了 vef.ApprovalModule,它就能在 root scope 解析出来——然后解析版本的表单 schema:

package options

import (
"context"
"encoding/json"
"fmt"

"github.com/coldsmirk/vef-framework-go/approval"
)

// OptionLabelPlan says how a list view should turn each selection field's
// stored value into a display label.
type OptionLabelPlan struct {
// Static maps a field key to its value-to-label table, built from the
// options the definition already carries.
Static map[string]map[string]string
// Remote maps a field key to the request a consumer replays ONCE to build
// that table itself. Only fields whose request has no bound parameters.
Remote map[string]*approval.RemoteOptionRequest
// PerRow lists the field keys whose option set depends on the row's own
// form values, so one lookup cannot translate the whole column.
PerRow []string
}

type OptionLabelService struct {
parser approval.FormSchemaParser
}

func NewOptionLabelService(parser approval.FormSchemaParser) *OptionLabelService {
return &OptionLabelService{parser: parser}
}

func (s *OptionLabelService) Plan(ctx context.Context, schema json.RawMessage) (*OptionLabelPlan, error) {
fields, err := s.parser.ParseFormFields(ctx, schema)
if err != nil {
return nil, err
}

plan := &OptionLabelPlan{
Static: make(map[string]map[string]string),
Remote: make(map[string]*approval.RemoteOptionRequest),
}

for _, field := range fields {
switch {
case field.Options != nil:
// Enumerated at deploy: translate locally, no lookup at all.
labels := make(map[string]string, len(field.Options))
for _, option := range field.Options {
labels[fmt.Sprint(option.Value)] = option.Label
}

plan.Static[field.Key] = labels

case field.OptionSource != nil:
request := field.OptionSource.Request
if request.HasBoundParams() {
plan.PerRow = append(plan.PerRow, field.Key)

continue
}

plan.Remote[field.Key] = request

default:
// Free-form field: the stored value is already what to display.
}
}

return plan, nil
}

// labelKey and valueKey apply RemoteOptionMapping's defaults.
func labelKey(mapping *approval.RemoteOptionMapping) string {
if mapping == nil || mapping.LabelKey == "" {
return "label"
}

return mapping.LabelKey
}

func valueKey(mapping *approval.RemoteOptionMapping) string {
if mapping == nil || mapping.ValueKey == "" {
return "value"
}

return mapping.ValueKey
}

值按字符串形态(fmt.Sprint)比较——框架自己的 select 校验就是这么做的——所以 1 和 "1" 命中同一个选项。

解析并不是唯一入口。同一份扁平列表已经持久化在 apv_flow_version.form_fields 上,并由流程版本详情响应以 formFields 返回,所以手里已经有那一行的宿主直接读 FlowVersion.FormFields 即可。只有当手里只有 schema 文档时才需要解析——实例详情 响应原样返回宿主文档 formSchema,从不返回派生字段。

对于成功部署的版本,OptionSource.Request 是可以信赖的:上面那条"缺操作即拒绝" 的校验会挡掉没有 request 的远程来源,所以在活下来的字段上它永远不是 nil。

上传字段​

内置 parser 会把设计器的 upload 组件投影为 upload 字段 kind。

上传字段的 columnType 由它的文件数量推断,而不是由 maxLength 推断: maxCount > 1 得到 json(值是一组存储 key 的数组——和 checkbox-group 的形状 相同),其余情况得到 text(单个存储 key)。maxLength 约束的是字符串长度, 而上传字段的界限是它接受多少个文件,在这里读它就会按错误的量纲给列定尺寸。字段上 显式声明的 columnType 依然优先,这一点和所有 kind 一致。

流程校验​

发起人规则​

isAllInitiationAllowed 与 initiators 互斥:当 isAllInitiationAllowed 为 true 时提交发起人规则会返回 ErrInitiatorsNotAllowed(code 40022)。 当限定时,至少需要一个发起人规则且至少包含一个 ID——空规则集或 ids 为空 的规则会返回 ErrInitiatorsRequired(code 40023),因为一个不选中任何人的规则 和完全没有规则一样,流程都无法被发起。

业务绑定 Schema​

流程使用 BindingMode 决定表单/实例数据如何与业务数据关联:

  • BindingStandalone(wire 值 standalone)—— 表单数据存在审批自己的表中(默认)。
  • BindingBusiness(wire 值 business)—— 流程通过 BusinessBindingConfig 挂接到已有的业务行。

业务绑定流程(BindingMode = business)在保存时会对照真实数据库 schema 校验。 当配置的 tableName 不存在时,返回 ErrBindingTableMissing(tableName)(code 40018,ErrCodeBindingSchemaInvalid)。当配置的列在表中不存在时,返回 ErrBindingColumnMissing(columnName)(同 code)。两者都会命名缺失的对象,让 操作员不必亲自查看 schema 就能修正配置。

流程模型与设计器枚举​

公开包暴露的流程设计和持久化模型包括 FlowCategory、Flow、FlowVersion、 FlowNode、FlowEdge、FlowInitiator、FlowNodeAssignee、FlowNodeCC、 FormFieldDefinition、FieldOption、FieldOptionSource、 RemoteOptionRequest、RemoteOptionMapping、DynamicParam、 FormSnapshot、ActionLog、 UserInfo 和 UrgeRecord(没有结构化的 FormDefinition 包装——宿主文档是 opaque 的,框架侧的表单形状只有 FormFieldDefinition 及它内嵌的那些类型)。流程版本状态使用 VersionStatus: VersionDraft(draft)、VersionPublished(published)、 VersionArchived(archived)。

Flow.Labels 是宿主自有的筛选元数据——保存时按共享 label 规则 校验、在列表查询中支持相等过滤;wire 形状见 RPC 资源。

其他流程设计器枚举:

枚举Wire values
InitiatorKinduser、role、department
ExecutionTypemanual、auto_pass、auto_reject
ConditionKindfield、expression
CCKinduser、role、department、form_field
CCTimingalways、on_approve、on_reject
FieldKindinput、textarea、select、number、date、upload、table
ColumnDataTypestring、text、integer、decimal、boolean、date、datetime、json
Permissionvisible、editable、hidden、required
OptionSourceKindremote
DynamicParamKindliteral、expression

下一步:实例运行时 了解设计好的流程启动后会发生什么。