资讯动态

基于Python自研低代码平台:表单构建器与工作流引擎实战

发布时间:2026/10/13 8:29:37 来源:尧图企业网站定制
先说个背景。年初的时候我接到一个内部系统改造任务公司里几十个审批流程散落在不同系统里——销售要提价申请、采购要走比价审批、研发要申请测试环境、人事要跑加班调休每个流程各自为政表单样式五花八门审批链路全靠邮件口头通知。团队当时的诉求很直接能不能用一套工具把做表单和跑流程这两件事统一起来让业务部门自己就能搭不用每次改动都找开发排期。这个需求摆上桌面后我研究了市面上的低代码平台也评估过买现成产品最后决定基于 Python 自研一套轻量级低代码集成方案可视化表单构建器 工作流引擎。整套东西从立项到跑通第一个真实流程前前后后花了大概两个月。这篇文章把整个过程中的建模思路、关键代码、踩坑教训都整理出来给那些同样想在公司内部低成本落地低代码能力的技术团队做个参考。别期待它像商业平台那么完整但作为表单配置化 审批流自动化的闭环这套方案在公司内部已经完全够用了。1. 我最终决定自己写引擎而不是买现成平台的三个理由1.1 现成低代码平台的三个死穴市面上的低代码平台我也实际试用过几款确实很漂亮拖拽组件、流程编排、权限管理一应俱全。但摆在企业内部落地场景里有三个问题绕不过去。第一个是数据出口问题。公司内部的审批数据包含合同金额、员工薪资、供应商信息这些数据过一遍外部云平台合规这一关就过不了。私有化部署版本当然有但报价通常是按用户数 模块数算的几十个流程跑下来年费不低而且后续每次加引擎功能都要单独谈价。第二个是定制深度的局限。商业平台能覆盖 80% 的标准场景但剩下 20% 的怪需求几乎都要靠厂商排期。比如我们有个场景流程审批到财务节点时需要自动读取上一环节填写的付款方式字段决定生成哪种类型的财务单据。这种跨表单字段联动的规则在商业平台里配置起来非常别扭。第三个是学习成本被低估了。低代码平台号称业务人员自己就能配但实际上业务人员根本不敢碰流程定义最后所有配置还是落回 IT 手里。既然最终都是开发在维护那还不如自己写一套至少能完全掌控代码逻辑。1.2 圈定自研边界只做两件事想清楚这些之后我给自己划了一条清晰的边界不做数据大屏不做报表分析不做应用市场生态只做两件核心事情——可视化表单构建器以及围绕表单数据流转的工作流引擎。这两件事都有非常成熟的理论模型可以参考表单本质是 JSON Schema 的编辑与渲染工作流本质是有限状态机 节点执行的编排。模型清晰工程量可控Python 生态里也有大量现成库可以加速。边界圈定之后技术选型就顺理成章了。后端用 FastAPI数据库用 PostgreSQL表单元数据存 JSONB 字段工作流定义也用结构化的 JSON 描述。前端可视化拖拽部分用 Vue3 实现但这篇文章重点在后端的建模与联动设计前端只提关键思路。2. 表单引擎的建模Schema 与数据必须分离2.1 表单定义的数据结构设计表单构建器最核心的一条设计原则就是表单的定义和表单提交的数据必须严格分离。定义是模板数据是实例。用户拖拽出一个输入框改的是模板员工填写并提交产生的是实例数据。这个分离做好了后面所有的版本管理、数据迁移、流程联动才有操作空间。表单定义我采用 JSON Schema 的子集做描述包含三部分布局信息、字段定义、校验规则。字段定义是核心每个字段有类型、名称、默认值、是否必填以及类型相关的扩展属性。文本类字段有最大长度和正则规则数字类字段有范围和单位下拉单选类字段有选项列表日期类字段有格式和默认值。下面是我实际在用的表单定义核心结构压缩掉注释后大概长这样{ schema_version: 1.0, fields: [ { key: apply_amount, label: 申请金额, type: number, required: true, placeholder: 请输入金额, extension: { min: 0, max: 1000000, unit: 元 } }, { key: vendor_name, label: 供应商名称, type: text, required: true, extension: { max_length: 200 } }, { key: payment_type, label: 付款方式, type: select, required: true, extension: { options: [ {value: bank_transfer, label: 银行转账}, {value: check, label: 支票}, {value: cash, label: 现金} ], default: bank_transfer } } ], layout: [apply_amount, vendor_name, payment_type] }变量的命名和结构可以随团队习惯调整但有两个点不能省key必须是全局唯一且不可随意修改的稳定标识type必须直接映射到一套明确的渲染器和校验器。label只是给人看的展示名业务上任何逻辑引用字段都只认key。2.2 后端的双重校验逻辑表单定义建好后后端要承担三重职责给前端渲染提供字段元数据、校验用户提交的数据、把合法数据落库。这里最容易犯的错误是只在前端校验前端校验只是用户体验层面的反馈后端校验才是数据安全真正的闸门。校验逻辑我用了一套按类型分发的规则器。每个字段类型注册一个校验器基础校验包括必填判断、类型转换检查扩展校验读取extension里的配置。正则、范围、枚举值这些统统在服务端再执行一遍。def validate_field(field_def: dict, value, all_data: dict) - list[str]: errors [] field_type field_def[type] key field_def[key] ext field_def.get(extension, {}) if field_def.get(required) and (value is None or str(value).strip() ): errors.append(f字段 {field_def[label]} 为必填项) return errors if value in (None, ): return errors if field_type number: try: num_val float(value) except (TypeError, ValueError): errors.append(f字段 {field_def[label]} 必须是数字) return errors if min in ext and num_val ext[min]: errors.append(f字段 {field_def[label]} 不能小于 {ext[min]}) if max in ext and num_val ext[max]: errors.append(f字段 {field_def[label]} 不能大于 {ext[max]}) elif field_type text: if max_length in ext and len(str(value)) ext[max_length]: errors.append(f字段 {field_def[label]} 长度不能超过 {ext[max_length]}) if pattern in ext: import re if not re.match(ext[pattern], str(value)): errors.append(f字段 {field_def[label]} 格式不正确) elif field_type select: valid_values [opt[value] for opt in ext.get(options, [])] if value not in valid_values: errors.append(f字段 {field_def[label]} 的选项值不存在) elif field_type date: from datetime import datetime try: datetime.strptime(str(value), %Y-%m-%d) except ValueError: errors.append(f字段 {field_def[label]} 日期格式应为 YYYY-MM-DD) return errors这套按类型分派的策略有个额外的好处新增一种字段类型时只需要注册对应的校验器函数表单定义、渲染器、校验器三个地方同步扩展不用动其他代码。2.3 实例数据落库JSONB 与关系型结构怎么选表单提交数据的落库方案我评估过三种路数全部字段拆成关系表的一列一字段、整个表单数据一个 JSON 字段、以及主表 JSON 辅助索引的混合方案。一列一字段的方案在表单频繁变动的场景下不现实每次改表单都要 ALTER TABLE几十个表单叠加起来后期数据库迁移会变成噩梦。纯 JSON 字段方案倒是灵活但查询和统计非常痛苦——PostgreSQL 的 JSONB 虽然支持表达式索引可一旦业务方提出我想按月统计所有租赁合同的金额总和JSON 里的嵌套访问会让 SQL 变得难以维护。我最终选的是混合方案。表单实例表保留一个主键、表单定义 ID、提交人、提交时间这些公共字段业务数据统一放进form_dataJSONB 字段。同时维护一个form_index_values表专门存储被标记为需要索引的关键字段值比如申请金额、供应商名称、流程状态。业务查询走索引表全文展示走 JSONB两边各有分工。CREATE TABLE form_instance ( id BIGSERIAL PRIMARY KEY, form_def_id BIGINT NOT NULL, submitter_id BIGINT NOT NULL, form_data JSONB NOT NULL, current_status VARCHAR(50) NOT NULL DEFAULT draft, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE form_index_values ( id BIGSERIAL PRIMARY KEY, instance_id BIGINT NOT NULL, field_key VARCHAR(128) NOT NULL, field_value TEXT NOT NULL, UNIQUE (instance_id, field_key) );这套设计初看多了一张表但实际维护成本很低。表单定义里给字段加一个indexed: true的标记后端在数据落库时自动提取这些字段写入索引表。需要做统计报表时直接对索引表写聚合 SQL索引字段加上 GIN 或普通 B-tree 索引都能很好地支撑。3. 工作流引擎的形态状态机加节点任务3.1 从 BPMN 简化出的四类节点工作流引擎是低代码平台里最容易想复杂的部分。BPMN 2.0 规范有几十种元素什么事件网关、信号捕获、边界事件完整实现一遍足够写一本书。但我实际跑业务场景后发现企业内部审批流程里反复出现的节点类型就四种开始节点、审批节点、条件分支节点、结束节点。开始节点负责触发流程实例创建从某个表单提交事件进入。审批节点是核心定义谁来审批、审批动作有哪些、通过或驳回后往哪个节点走。条件分支节点根据流程上下文中的某个字段值决定走哪条路径。结束节点标记流程终止。每个节点我用一个通用结构描述核心字段包括节点 ID、节点类型、节点名称、以及next映射。next不是简单的一个值而是一个「出口列表」里面声明了到达下一个节点的条件表达式。条件表达式用一套轻量的规则 DSL 描述JSON 序列化存在流程定义的node_defs字段里。下面是一个典型的三级审批流程定义包含了条件分支的处理逻辑{ flow_name: 采购合同审批, nodes: { start: { type: start, name: 发起申请, next: [ {target: dept_approve, condition: true} ] }, dept_approve: { type: approve, name: 部门负责人审批, assignee_rule: role:dept_leader, next: [ {target: condition_amount, condition: true} ] }, condition_amount: { type: condition, name: 金额分支, next: [ {target: cto_approve, condition: form.apply_amount 10000}, {target: finance_approve, condition: form.apply_amount 10000} ] }, cto_approve: { type: approve, name: CTO审批, assignee_rule: role:cto, next: [ {target: finance_approve, condition: true} ] }, finance_approve: { type: approve, name: 财务复核, assignee_rule: role:finance_reviewer, next: [ {target: end, condition: true} ] }, end: { type: end, name: 流程结束 } } }这套设计的价值在于整个流程定义是纯数据可以存在 PostgreSQL 里改流程不需要发版后台改完立即生效。assignee_rule字段支持按角色、按用户、按发起人所在部门的上级等多种规则解析器在运行时动态算出具体审批人。3.2 节点推进引擎与状态机的真正实现流程实例的状态变化我严格按状态机来管理。每个流程实例有current_node_id字段表示当前所在节点状态流转表记录了从哪个节点、在什么条件下、可以跳到哪个节点。唯独不能出现状态随意跳的情况否则后面排查审批记录时根本理不清时间线。推进引擎的核心是一个纯函数输入当前节点 ID、流程实例上下文、用户执行的动作输出下一个节点 ID 和新的上下文。这个函数是纯的不直接操作数据库方便做单元测试和幂等控制。from enum import Enum from typing import Any class NodeType(Enum): START start APPROVE approve CONDITION condition END end class WorkflowAction(Enum): SUBMIT submit APPROVE approve REJECT reject RETURN return def evaluate_condition(expr: str, context: dict) - bool: # 简易表达式求值器支持数字比较和 in 判断 # 生产环境可换成受控的表达式解析库 field_path expr.split()[0] operator expr.split()[1] value expr.split()[2] actual context for part in field_path.split(.): actual actual.get(part) if operator : return float(actual) float(value) if operator : return float(actual) float(value) if operator : return str(actual) value.strip(\) if operator in: return actual in value.strip([]).split(,) return False def advance(current_node_id: str, flow_def: dict, context: dict, action: WorkflowAction): current_node flow_def[nodes][current_node_id] if current_node[type] approve: if action WorkflowAction.REJECT: # 驳回统一回到父节点或指定返回节点 return current_node.get(reject_target, start), False if action WorkflowAction.RETURN: return current_node.get(return_target, start), True for transition in current_node.get(next, []): if evaluate_condition(transition[condition], context): return transition[target], True return None, False这个简化版引擎在实际运行中的表现基本覆盖了全部常规审批场景。条件表达式中的form.apply_amount引用的是表单数据里的字段值这为下一节要讲的表单与流程联动埋下了伏笔。生产环境建议把表达式求值替换为受控的安全解析库避免直接拼接字符串执行。3.3 任务表与待办中心的落地流程实例有独立的状态而审批人真正关心的其实是我有哪些待办。待办中心的数据来源于任务表每次流程推进到某个审批节点时根据assignee_rule解析出具体的审批人写入待办任务记录。任务表的设计我踩过一次坑最初设计是task字段存审批人 ID后来发现审批人角色一变历史待办会悬挂在旧用户头上。最终改成给任务表存任务归属规则快照实际可处理人通过规则实时计算配合冗余的assignee_ids字段做默认展示。任务状态包括待办、已处理、已转办、已驳回每次操作记录都追加到task_logs表保证审批历史可追溯。CREATE TABLE workflow_task ( id BIGSERIAL PRIMARY KEY, instance_id BIGINT NOT NULL, node_id VARCHAR(64) NOT NULL, task_name VARCHAR(128) NOT NULL, assignee_rule VARCHAR(255) NOT NULL, assignee_ids BIGINT[] NOT NULL, status VARCHAR(20) NOT NULL DEFAULT pending, started_at TIMESTAMPTZ NOT NULL DEFAULT now(), finished_at TIMESTAMPTZ ); CREATE TABLE task_logs ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL, instance_id BIGINT NOT NULL, operator_id BIGINT NOT NULL, action VARCHAR(20) NOT NULL, comment TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );待办查询最核心的 SQL 就是按assignee_ids ARRAY[当前用户ID]扫描任务表配合 status 索引几千条待办数据量下性能没有任何问题。如果未来任务量上到百万级再考虑把 pending 任务从大表里拆出来单独维护。4. 表单与工作流如何拧成一股绳4.1 表单字段如何成为流程条件前面讲了表单引擎和工作流引擎各自的模样但低代码平台真正的难点在于两者怎么联动。流程条件分支里的form.apply_amount、form.payment_type是从表单实例数据里动态取值的。这就意味着流程定义和后端引擎之间必须约定好流程可以读取哪些表单数据。我的做法是引入流程定义里的form_binding配置段显式声明当前流程绑定的表单定义 ID以及流程条件表达式可访问的字段白名单。白名单的意义在于安全和稳定——用户只能引用绑定的字段写错字段名在流程定义保存阶段就能校验出来而不是等到流程跑起来才报错。def resolve_form_context(flow_def: dict, form_data: dict) - dict: binding flow_def.get(form_binding, {}) allowed_fields binding.get(allowed_fields, []) return {form: {k: v for k, v in form_data.items() if k in allowed_fields}}这个函数把表单实例数据裁剪成流程上下文送入推进引擎。条件分支的表达式求值只会接触到白名单内的字段既防止了流程引擎被恶意字段干扰也让流程定义的自包含性更强。4.2 表单提交后自动拉起流程实例联动链路的启动点设计得很简单表单实例落库成功后发布一个领域事件FormSubmittedEvent。工作流引擎监听这个事件检查该表单定义是否绑定了流程定义。如果有就创建流程实例把表单数据快照塞进流程上下文然后调用推进引擎走到第一个审批节点生成首批待办任务。这中间最关键的是幂等控制。事件发布和流程创建之间隔着消息组件如果消费者重复收到同一条事件就会创建出两个流程实例。我在流程实例表上加了一个form_instance_id唯一约束重复消费时直接捕获唯一约束冲突异常不做任何额外处理。这个方案简单到不需要引入分布式事务实际运行几个月下来也没出现过重复实例问题。async def handle_form_submitted(event): form_instance_id event[form_instance_id] form_def_id event[form_def_id] # 查询流程绑定关系 flow_def await workflow_repo.get_by_form_def(form_def_id) if flow_def is None: return try: instance_id await workflow_repo.create_instance_with_unique_form( form_instance_idform_instance_id, flow_def_idflow_def.id, initial_contextevent[form_data] ) except UniqueViolation: logger.warning(fduplicate workflow creation skipped: {form_instance_id}) return # 推进到第一个审批节点 await workflow_engine.advance(instance_id, flow_def, actionWorkflowAction.SUBMIT)4.3 表单改版后旧流程实例的前向兼容表单一旦投入使用就一定会面临改版。商务流程里销售申请单刚开始没有客户等级字段三个月后业务方要求加上这时候麻烦来了已提交的旧数据没有这个字段条件分支如果引用了form.customer_level表达式求值就会取到 None流程直接推进不下去。我处理的方法是双轨制流程实例上下文保留提交时的表单数据快照条件表达式对缺失字段一律按不满足处理并在流程日志里留下告警。同时后端配套一个字段迁移工具业务方确认后可以批量把历史实例的缺失字段补上默认值。这个工具是低代码平台的幕后英雄日常开发里很容易被忽略真正遇到线上流程卡死时才会意识到它多重要。字段引用完整性检查也前置到了流程定义保存阶段。保存流程定义时后端调表单定义读取接口把白名单里所有字段 key 和表单字段列表做 diff发现引用不存在的字段直接报错。这个校验能挡住绝大多数改表单后流程悄悄崩了的问题。5. 实测阶段踩过的坑与最终技术选型复盘5.1 三个让我印象深刻的线上故障第一个坑是审批并发重复处理。两个审批人同时打开同一个待办一个点了通过另一个也点了通过。初版代码没做任务状态乐观锁两个请求都通过了任务校验最后一个写库的请求覆盖了前一个的处理结果。修复方案是给任务表加version字段更新时带WHERE status pending AND version ?条件更新行数为零就返回任务已处理。这是个经典问题但低代码平台里特别容易中招因为流程推进引擎经常被业务方要求高效快速而忽略了并发控制。第二个坑是角色成员变化导致历史任务错乱。最初实现审批人解析时直接把人员 ID 实时写入任务表某次组织架构调整后所有历史待办集中落到一批新领导头上老领导那边瞬间清空业务直接乱了套。后来改成任务表只存规则快照展示待办时实时解析规则算可处理人彻底消除了这个问题。代价是待办查询从纯索引查询变成了索引加实时计算但数据量可控性能损失可以接受。第三个坑是 JSONB 大字段带来的查询缓慢。有段时间我们把表单所有数据全塞进form_data包括一些冗长的富文本内容单行数据轻松超过几 KB。结果统计接口按 JSONB 字段过滤时查询计划扫描的页数暴增。优化方案就是前面提到的索引表方案把高频过滤字段抽出来单独存JSONB 只负责展示和低频查询。从那以后我凡是设计 JSONB 存储都会同步考虑哪些字段需要抽出来建索引。5.2 技术栈选型清单与性能取舍整套系统跑下来最终的技术栈相当精简全是 Python 生态里比较成熟稳定的选择组件选型备注Web 框架FastAPI异步性能好自动生成接口文档前后端联调效率高ORMSQLAlchemy 2.x灵活JSONB 支持完善迁移工具成熟数据库PostgreSQLJSONB GIN 索引是表单场景的天然搭档消息组件内部事件总线表单和流程在同一进程内通信暂未引入独立 MQ缓存Redis用户权限快照、角色成员列表缓存前端Vue3 自研拖拽组件表单渲染器和流程画布都在前端侧性能取舍上我们的核心指标是表单渲染接口的响应时间还是审批操作的完成时间。实测下来表单定义接口加一层进程内缓存后渲染接口响应在 20 毫秒以内审批操作因为涉及任务状态更新和流程推进两步平均耗时 50 毫秒。这个水平对内部系统来说完全够用。如果未来要支撑几千人同时访问优化的方向很明确流程定义和表单定义全部走本地缓存推进引擎改为消息队列异步执行数据库连接池扩容这些都是平滑可扩展的。5.3 接下来可以继续扩展的方向这套低代码骨架跑通后整个团队最大的感受是复杂的不是代码而是模型边界的划定。表单定义和数据分离决定了你能不能在表单改版后依然兼容旧数据流程节点类型的克制决定了引擎能不能被非技术背景的人理解字段白名单和版本快照决定了流程定义有没有可能失控。这些建模决策比单纯堆功能重要得多。如果后续还要继续演进我手上排了几个方向。一是给审批节点增加会签和或签两种多人审批模式核心是任务表的拆分逻辑让一个大任务拆成多个子任务、任一或全部完成才算节点通过。二是增加超时提醒机制定时扫描 pending 状态且超过时限的任务自动推送提醒超时未处理的可配置转交或升级。三是补充完整的操作审计和流程分析视图让每个节点平均耗时、驳回次数、常见退回原因都能量化出来反哺流程优化。最后分享一个我个人的体会低代码平台这个领域市面上不缺漂亮的产品但企业内部真正需要的是一个模型清晰、边界克制、可随时改代码的自有工具。表单构建器和工作流引擎的组合已经是大部分企业数字化流程的刚需底座。如果你也在考虑自研方案建议从这篇文章里的建模思路出发先跑通一个真实流程再逐步完善别一上来就想着对标商业产品。把最核心的两条链路——表单配置化、流程自动化——做扎实就已经值回所有开发成本。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑