每个团队手里都攒着一堆「就差一个表格页面」的需求:订单要能改状态、库存要能按条件查、请假单要能走一遍审批。后台表格、查询页、审批流程这三类东西,需求方觉得是小活,研发排期却一排就是一两周。
常规做法是 Spring Boot 加 Vue 自研一套 CRUD,可每来一个新表就重写一遍列表、筛选、分页和按钮逻辑,写完还得处理登录、权限、部署。代码量不大,但重复度极高,改一个字段要动三层。
本文分享一套把 PostgreSQL 直接变成业务系统的落地路径:ToolJet 做界面与流程编排 + PostgreSQL 做数据与约束 + Caddy 做远程访问收口,全程 Docker 起服务,可直接落地。
一、重复劳动的现场:三类需求吃掉八成排期
先把这些「小需求」拆开看,它们的共同点非常明确:
- 表格页 :列表、筛选、分页、行内编辑,本质是
SELECT加一个UPDATE按钮。 - 查询页:几个输入框加一个条件组合,本质是一条参数化 SQL。
- 流程页:提交、审核、驳回、通知,本质是状态机加一次消息推送。
- 共同点:全部围绕已有的表结构,逻辑不超过一屏代码,却要套上完整的前后端工程。
查询 SQL → 状态流转 → 通知推送,这条链路在几乎每个内部系统里都会原样出现一遍。
**核心结论:**这些需求缺的不是代码能力,而是一个不用写代码的运行时。
二、选型依据:为什么落点是 ToolJet 而不是自研
把几个常见方案按「内部工具」这个场景比一遍:
| 维度 | ToolJet | Appsmith | 自研 CRUD |
|---|---|---|---|
| 开源与部署 | CE 版可自托管 | CE 版可自托管 | 完全自主 |
| PostgreSQL 支持 | 一等公民,自带查询器 | 支持,需装插件 | 需自己写 DAO |
| 内置工作流 | Workflow 独立模块 | 较弱,靠 JS 编排 | 自己实现状态机 |
| 权限粒度 | 组 + 页面 + 查询级 | 组 + 页面级 | 自己设计 |
| 新增一张表的成本 | 十分钟级 | 半小时级 | 一到两天 |
- 自托管优先:ToolJet CE 全部组件跑在自己机器上,业务表不出内网。
- SQL 即逻辑:查询直接写原生 SQL,复杂 join 不用迁就 ORM。
- Workflow 独立:审核流程不塞在页面 JS 里,单独可视化编排。
**核心结论:**内部工具的胜负手是「改一个字段的成本」,ToolJet 把这个成本压到了配置层。
三、起服务:Docker Compose 一次拉起 ToolJet 与元数据库
ToolJet 自身的用户、应用、查询定义也要存库,所以起两个 PostgreSQL:一个存元数据,一个放业务数据。
下面这段 compose 用 CE 版镜像起 ToolJet 与元数据库,业务库可以复用现有实例:
yaml
services:
tooljet:
image: tooljet/tooljet-ce:latest
depends_on: [postgres]
environment:
- NODE_ENV=production
- TOOLJET_HOST=https://tooljet.internal
- DB_HOST=postgres
- DB_USER=tooljet
- DB_PASS=change_me
- LOCKDOWN_DEFAULT_WORKSPACE=true
ports: ["8080:8080"]
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: tooljet
POSTGRES_PASSWORD: change_me
volumes: ["pgdata:/var/lib/postgresql/data"]
volumes:
pgdata:
强约束:锁库账号 → 只读业务账号 → 页面级白名单,三个开关在起服务时就要定下来。
四、接数据源:把 PostgreSQL 挂进 ToolJet 并做好账号隔离
业务库绝不能用超级用户接进来,先在库里准备好专用账号:
sql
-- 只给 ToolJet 查询用的只读账号
CREATE ROLE tj_reader LOGIN PASSWORD 'strong_pwd';
GRANT CONNECT ON DATABASE biz TO tj_reader;
GRANT USAGE ON SCHEMA public TO tj_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO tj_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO tj_reader;
- 账号分级 :查询页绑只读账号,写操作单独绑一个只有
INSERT/UPDATE权限的账号。 - SSL 强制:数据源配置里勾选 Require SSL,跨机器访问不走明文。
- Schema 白名单:只暴露业务 schema,元数据库不出现在数据源列表里。
- 连接池留意 :ToolJet 默认每个查询新建连接,高并发页面要给 PostgreSQL 留足
max_connections。
**核心结论:**权限收在数据库层最稳,页面层再怎么配也拦不住一条手滑的 DELETE。
五、后台表格:从建表到 CRUD 页面的字段设计
建表时就把页面要用的东西设计进去,能省掉大量后续补丁:
| 字段 | 类型 | 设计意图 |
|---|---|---|
status |
varchar + CHECK | 状态只允许白名单值,页面下拉框直接读约束 |
created_at |
timestamptz 默认 now() | 列表默认按它倒序,分页稳定 |
updated_by |
varchar | 行内编辑时由页面自动填入当前账号 |
version |
int 默认 0 | 乐观锁,防止两个人同时改一行 |
页面侧的操作建议:
- 列表绑定查询 :拖一个 Table 组件,数据源指向上一步建好的
SELECT查询。 - 行内编辑走 UPDATE :编辑保存动作绑定
UPDATE ... WHERE id = {``{table.selectedRow.id}} AND version = {``{...}}。 - 行数为 0 也要能看:加一个空状态提示,避免需求方以为系统挂了。
**核心结论:**把状态白名单、时间戳和乐观锁放进 DDL,页面就只剩拖拽配置。
六、查询页:参数化查询与前端联动
查询页的核心是一条绑定页面变量的参数化 SQL,ToolJet 用 :参数名 或 {``{组件值}} 占位:
sql
-- 绑定三个页面控件:状态下拉、起止日期
SELECT o.id, o.amount, o.status, c.name AS customer,
ROUND(EXTRACT(EPOCH FROM (o.paid_at - o.created_at))/3600, 1) AS hours_to_pay
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = :status
AND o.created_at BETWEEN :from AND :to
ORDER BY o.created_at DESC
LIMIT 100;
- 先跑再绑:查询编辑器里先填常量验证 SQL 正确,再把常量换成绑定表达式。
- 分页放数据库 :别在前端切片,
LIMIT/OFFSET或游标交给 PostgreSQL。 - 导出复用查询:导出按钮直接复用同一个查询,避免维护两份 SQL。
**核心结论:**一条参数化 SQL 加三个控件绑定,就等于一个查询页。
七、流程页:用 Workflow 把审核和通知串起来
ToolJet 的 Workflow 独立于页面存在,适合放状态机。链路长这样:
表单提交 → 创建待审记录 → 审核人收到通知 → 通过则改状态并写日志 / 驳回则回到申请人
Workflow 节点之间用 JavaScript 节点做格式转换,例如把金额从分转成元再推送给审核人:
js
// Workflow 的 Code 节点:整理待推送的审批摘要
const rows = queries.fetchPending.list;
const items = rows.map(r => ({
id: r.id,
applicant: r.applicant,
amount: (r.amount / 100).toFixed(2)
}));
const total = items.reduce((s, r) => s + Number(r.amount), 0);
return { items, summary: `共 ${items.length} 单,合计 ${total.toFixed(2)} 元` };
- 审核动作落库:通过/驳回按钮只触发 Workflow,页面不直接写表,避免状态被绕过。
- 日志单独一张表 :谁在什么时间改了什么状态,全部追加写入
audit_log。 - 失败要重试:通知节点失败不应阻断状态变更,配置失败分支只记日志。
**核心结论:**状态变更全部走 Workflow,页面只负责收集参数,流程才不会被旁路。
八、权限体系:组、页面、查询三层管控
权限配错是内部工具最常见的事故源,按三层来收口:
- 用户组:按角色建组(申请员、审核员、管理员),人员只进组不单独授权。
- 页面可见性:审核页只对审核员组可见,别用「藏按钮」代替隐藏页面。
- 查询可执行性:高危查询(如批量删除)单独建查询并绑定管理员组,页面拿不到就调不动。
检查顺序建议是组 → 页面 → 查询逐层往下验,反着查很难定位是哪一层漏了。
**核心结论:**页面隐藏只是体验,查询不可执行才是安全。
九、远程访问:反向代理与 HTTPS 收口
ToolJet 直接暴露公网等于把数据库查询能力公开,前面必须加一层代理:
bash
# Caddy 配置:自动签证书并转发到本机 ToolJet
tooljet.example.com {
reverse_proxy 127.0.0.1:8080
header_up X-Forwarded-Proto {scheme}
encode gzip
}
- 只开 443 :
8080仅监听127.0.0.1,防火墙不放行。 TOOLJET_HOST必须改成公网域名:否则邮件里的链接、OAuth 回调都指错地址。- 加一层 IP 白名单:远程访问场景下,把办公网出口 IP 写进代理规则。
- 先内网验证再上公网:证书、回调、WebSocket 三项都在内网走通一遍。
**核心结论:**远程访问的正确姿势是「代理收口 + 域名固定 + 白名单」,而不是把 8080 直接映射出去。
十、上线前检查清单:踩坑与排错对照表
把上线前最容易翻车的几项列成清单,逐条勾:
| 现象 | 常见原因 | 处理动作 |
|---|---|---|
| 页面打开但查询全部超时 | 业务库连接数被元数据库占满 | 分库并调大 max_connections |
| 保存页面报权限错误 | 业务账号被误配成只读却执行了写查询 | 拆分读写两个数据源 |
| 远程访问白屏 | TOOLJET_HOST 仍是 http://localhost |
改为公网域名并重启容器 |
| 审核后状态没变 | 审核按钮直接写表,绕过了 Workflow | 把写操作改为触发 Workflow |
| 表格数据串行 | 两人同时编辑同一行 | 启用 version 乐观锁 |
- 备份别只备份业务库:ToolJet 元数据库丢了,所有页面和流程定义一起丢。
- 升级先在测试实例演练:CE 版跨大版本升级要跑迁移脚本,直接滚生产容易卡住。
**核心结论:**上线前把「连接、权限、域名、流程、并发」五项各验一次,能挡掉九成故障。
结语
n后台表格、查询和流程这三类需求,几乎每个团队都会反复遇到。把它们从自研 CRUD 里解放出来,用 ToolJet 承担界面与编排、PostgreSQL 承担数据与约束、Caddy 承担远程访问收口,新增一个业务模块就从「排期一周」变成「配置半小时」。
这套方案的价值不在省代码,而在于把权限、审计和流程闭环沉淀成平台能力,业务变化时只改配置不动工程。把重复的 CRUD 交给运行时,把精力留给真正有业务含义的规则。