自定义工作流完全指南:从零到一键部署
本文档级别:嚼碎了喂嘴里。假设你从未写过脚本,按顺序读、按顺序做,就能写出和系统内置「一键部署小氢云脚本」同等级的自动化流水线。
目录
- 30 秒搞懂:这到底是什么
- 管理端:第一次打开编辑器(逐步点击)
- 脚本运行的底层机制(知道这些就不踩坑)
- 命名与参数:一张表记牢
- host_id 与 host_source:最容易搞混的点
- 第一个能跑的脚本(逐行解释)
- 内置节点返回值对照表(必收藏)
- 日志工具:直接复制粘贴
- 通用工具函数:示例脚本逐函数讲解
- 与用户弹窗交互:request_user_input 全解
- 八步部署流水线:每一步干什么、为什么、怎么写
- Go 项目查重:为什么不用 get_go_project_detail
- 启动失败自动重建:容错逻辑
- 邮件通知模板
- 自定义节点:从创建到在工作流里调用
- 发布、上架、挂到用户主机
- 执行结果面板:怎么看日志
- 故障排查矩阵
- 完整配置区 + 最小可运行模板
- 附录:常用内置节点速查
1. 30 秒搞懂:这到底是什么
一句话: 在管理后台写一段 JavaScript,里面调用一个个「节点函数」(如上传文件、建数据库),系统自动在宝塔面板上帮你执行。
三个角色:
| 角色 | 是什么 | 举例 |
|---|---|---|
| 工作流 | 一整段 JS 脚本 + 名称/状态 | 「一键部署小氢云」 |
| 内置节点 | 系统写好的函数,内部调宝塔 API | upload_file({...}) |
| 自定义节点 | 你自己扩展的函数 | my_api_call({...}) |
执行时发生什么:
- 你点「运行」或用户在主机详情点快捷按钮
- 服务端用 Goja 引擎跑你的 JS
- 每调用一次
xxx({...}),记为一步,结果推送到前端 console.log的内容出现在「控制台日志」- 某步失败 → 立刻中断,后面不再跑(除非你自己用
if吃掉错误)
2. 管理端:第一次打开编辑器(逐步点击)
2.1 进入路径
左侧菜单 → 工作流管理 → 右上角「新建工作流」 或直接访问:/workflow/editor
2.2 界面分区(从左到右)
| 区域 | 作用 |
|---|---|
| 操作参考(左栏) | 按分类列出所有节点;搜索、展开详情、点「插入」 |
| Monaco 编辑器(中间) | 写脚本;支持语法检查、Ctrl+S 保存、缩放 |
| 顶部工具栏 | 工作流名称、描述、执行主机 ID、保存/发布/运行 |
2.3 第一次运行(逐步)
- 名称填:
测试检查文件 - 中间编辑器全选删除,粘贴 第 6 节 的代码
- 顶部 执行主机 ID 填一个真实主机 ID(在主机列表可查)
- 点 运行(无需先保存,会直接跑编辑器里的内容)
- 弹出 执行结果 抽屉:
- 上方:成功/失败
- 步骤明细:每一步调了哪个函数、参数、返回值
- 控制台日志:
console.log输出
2.4 保存与发布
| 按钮 | 效果 |
|---|---|
| 保存草稿 | 写入数据库,状态=草稿 |
| 发布 | 草稿 → 已启用(管理端可执行) |
| 列表页「上架」 | 用户端可见、可绑定快捷入口 |
调试技巧: 改脚本 → 直接点运行 → 看日志 → 改完再点保存。不必每次保存。
3. 脚本运行的底层机制
3.1 脚本被包在 IIFE 里
系统自动把你的脚本包成:
(function(){
// 你的脚本
})();
所以你可以在最外层用普通语句,不要用 return 退出(除非配合 end_workflow)。
3.2 host_id 自动注入
当你在编辑器填了「执行主机 ID」,或用户在用户中心对某台主机跑工作流,系统会在脚本最开头插入:
var host_id = 123;
var hostId = 123;
// 然后才是你的脚本
并且会删掉你脚本里原有的 const/let/var host_id = ... 和 hostId = ... 赋值行,避免冲突。
3.3 节点调用 = 一步操作
var r = check_file_exists({ host_source: "auto", dirPath: "/", fileName: "a.txt" });
等价于:校验参数 → 调后端 → 宝塔查文件 → 把结果塞回 r。
失败时: 抛异常,脚本中断,执行结果标记失败。
成功时: 返回对象;若节点没定义特殊字段,自动补 status: true, code: 200。
3.4 暂停与续跑(request_user_input)
调用 request_user_input 时:
- 后端保存当前已完成的步骤快照
- 向前端推送「等待输入」
- 用户填完点确定 → 调用 resume 接口 → 从断点继续,已完成步骤不会重复执行
用户点取消 → 返回 { cancelled: true },由你决定是 end_workflow 还是 throw。
3.5 超时
整段脚本最长 5 分钟。单步超时看各节点配置(自定义节点默认 30 秒)。
4. 命名与参数:一张表记牢
| 层级 | 风格 | 示例 |
|---|---|---|
| 传给节点的参数键 | snake_case | host_id, host_source, file_name |
| 脚本内部变量 | camelCase(推荐) | hostInfo, siteRootPath |
| 节点函数名 | snake_case | check_file_exists, create_go_project |
铁律: 参数对象里的键名必须和操作参考里一致,写错键 = 参数传不进去。
5. host_id 与 host_source
5.1 两种 host_source
| 值 | 含义 | 典型场景 |
|---|---|---|
"auto" |
用执行上下文的主机 | 用户中心、编辑器已填主机 ID |
"custom" |
脚本里显式传 host_id |
管理端批量、示例部署脚本 |
5.2 auto 模式下路径怎么拼
设当前主机名为 shop.example.com,站点根目录固定为:
/www/wwwroot/shop.example.com
此时:
check_file_exists({
host_source: "auto",
dirPath: "/", // → /www/wwwroot/shop.example.com
fileName: "index.php"
});
check_file_exists({
host_source: "auto",
dirPath: "public/uploads", // → /www/wwwroot/shop.example.com/public/uploads
fileName: "logo.png"
});
5.3 custom 模式
check_file_exists({
host_source: "custom",
host_id: hostId, // 数字或变量
dirPath: "/www/wwwroot/shop.example.com",
fileName: "index.php"
});
5.4 本地文件 upload 路径(与 host 无关)
| 写法 | 含义 |
|---|---|
@/app.zip |
管理端 doc 目录 下的 app.zip |
/app.zip |
同上(文件管理器虚拟路径) |
| 绝对路径 | 服务器上真实路径(需在 doc 目录内) |
上传前: 把 zip 放到 doc 目录,否则报「本地文件不存在」。
6. 第一个能跑的脚本
复制下面整段 → 粘贴到编辑器 → 填执行主机 ID → 运行。
// ===== 第 1 步:检查站点根目录有没有 index.php =====
var result = check_file_exists({
host_source: "auto",
dirPath: "/",
fileName: "index.php"
});
// ===== 第 2 步:打印结果(在「控制台日志」里看)=====
console.log("API 是否成功:", result.status);
console.log("文件是否存在:", result.found); // 注意:字段名是 found,不是 exists
// ===== 第 3 步:根据结果分支 =====
if (result.found) {
console.log("index.php 已存在,无需处理");
} else {
console.log("index.php 不存在,可以在这里继续 upload_file 或 create_file");
}
逐行解释:
check_file_exists:节点函数,查远程目录里有没有某文件host_source: "auto":用当前主机,不用手写路径里的主机名dirPath: "/":站点根目录(不是服务器根/)fileName:只写文件名,不含路径result.found:true=找到,false=没找到(没找到不算 API 失败)result.status:API 调用本身是否成功
7. 内置节点返回值对照表
写
if判断前先看这张表,不同节点字段名不一样。
7.1 通用成功
大多数写操作(create、delete、unzip…)成功时:
{ "status": true, "code": 200 }
7.2 check_file_exists
| 字段 | 类型 | 含义 |
|---|---|---|
| status | bool | API 成功 |
| code | int | 200=找到,404=未找到 |
| found | bool | true=文件存在 |
if (result.found) { /* 存在 */ }
if (!result.found) { /* 不存在,但 API 可能仍 status=true */ }
7.3 database_exists
| 字段 | 含义 |
|---|---|
| value | true/false 库是否存在 |
| status | 查询是否成功 |
// 必须用 value,不要直接用 status 判断库是否存在
function isDatabaseExists(r) {
return !!(r && r.value === true);
}
7.4 upload_file
| 字段 | 含义 |
|---|---|
| status | 是否成功 |
| progress | 0-100,完成时 100 |
| file_size | 字节数 |
| remote_path | 远程完整路径 |
执行过程中前端可通过 WebSocket 看实时上传进度。
7.5 port_rule_exists
| 字段 | 含义 |
|---|---|
| exists | 端口是否已在防火墙放行 |
7.6 get_go_project_detail
| 字段 | 含义 |
|---|---|
| found | 项目是否存在 |
| status | found=true 时为 true |
注意: 部署脚本故意不用此节点做查重,见 第 12 节。
7.7 start_go_project
失败时不抛错,返回 { status: false, code: 404, error: "..." },需自己判断。
7.8 request_user_input
| 字段 | 含义 |
|---|---|
| cancelled | 用户是否取消 |
| value | 用户输入(confirm 类型为 true/false) |
7.9 bind_user_domains_local
| 字段 | 含义 |
|---|---|
| skipped | 是否全部重复跳过 |
| added | 本次新增域名数组 |
| total_count | 绑定后总数 |
| local_only | 恒为 true(只写本系统,不调宝塔) |
7.9 replace_config_content
| 字段 | 含义 |
|---|---|
| modified_keys | 成功替换的点分路径列表,如 ["database.host","app.base_url"] |
8. 日志工具
直接复制到脚本顶部,和示例脚本保持一致:
var logSequence = 0;
function padLogIndex(value) {
return value < 10 ? "0" + value : String(value);
}
// 主步骤:[01] [02] … 自动递增
function logInfo(message) {
logSequence += 1;
console.log("[" + padLogIndex(logSequence) + "] " + message);
}
// 子步骤:树形缩进,不占主序号
function logDetail(message) {
console.log(" └─ " + message);
}
// 阶段标题:固定 01~08,和流程图对齐
function logPhase(phaseNo, title) {
console.log("[" + padLogIndex(phaseNo) + "] " + title);
}
输出效果:
[01] 初始化部署上下文 [01] 主机: demo.com | 用户: zhangsan └─ siteRootPath: /www/wwwroot/demo.com [02] 检测并上传部署包 [03] 远程包已存在,跳过上传
9. 通用工具函数
以下全部来自 一键部署小氢云脚本.js,建议原样复制。
9.1 sanitizeGoProjectName / sanitizeDbName
宝塔 Go 项目名、MySQL 库名不能含 . 和 -:
function sanitizeGoProjectName(hostName) {
return String(hostName || "").replace(/\./g, "_").replace(/-/g, "_");
}
// shop.example.com → shop_example_com
数据库名规则相同,用 sanitizeDbName。
9.2 randomDbPassword
新建库且主机记录无密码时,生成 16 位随机密码(去掉易混淆字符 0/O/1/l/I)。
9.3 normalizeDomainInput
用户可能输入 https://shop.com/ 或 shop.com/path,统一成 shop.com:
- 去首尾空格、末尾
/ - 去
http:///https:// - 去路径部分
9.4 assertActionSuccess
function assertActionSuccess(actionResult, actionName) {
if (!actionResult || actionResult.status !== true) {
throw new Error(actionName + " 失败: " + JSON.stringify(actionResult || { status: false }));
}
return actionResult;
}
用于 create_database、replace_config_content 等必须成功的步骤。
9.5 isDatabaseExists
function isDatabaseExists(checkResult) {
return !!(checkResult && checkResult.value === true);
}
10. 与用户弹窗交互
10.1 全部 input_type
| input_type | 界面 | value 类型 |
|---|---|---|
| text | 单行输入框 | 字符串 |
| textarea | 多行 | 字符串 |
| number | 数字框 | 数字 |
| password | 密码框 | 字符串 |
| select | 下拉(不可输入) | 选项 value |
| combobox | 下拉 + 可手输 | 字符串 |
| confirm | 确认框 | 确认=true |
10.2 示例脚本里的域名选择(完整逻辑)
function requestBindDomain(hostInfo) {
var boundDomains = parseBoundDomains(hostInfo);
// SSL 开了就用 https
var enableHttps = hostInfo && (
hostInfo.ssl_enabled === 1 || hostInfo.ssl_enabled === true ||
hostInfo.ssl_force_https === 1 || hostInfo.ssl_force_https === true
);
var urlScheme = enableHttps ? "https" : "http";
// 已有域名做成下拉选项
var domainOptions = [];
for (var i = 0; i < boundDomains.length; i++) {
domainOptions.push({ label: boundDomains[i], value: boundDomains[i] });
}
var inputResult = request_user_input({
title: "绑定域名",
content: boundDomains.length ? "请选择或输入域名" : "请输入要绑定的域名",
input_type: "combobox",
placeholder: "example.com",
options: domainOptions,
required: true,
field_key: "domain"
});
if (inputResult.cancelled) {
end_workflow({ message: "用户取消了域名输入" }); // 成功结束,不算失败
}
var selectedDomain = normalizeDomainInput(inputResult.value);
if (!selectedDomain) throw new Error("域名不能为空");
return {
bindDomain: selectedDomain,
appBaseUrl: urlScheme + "://" + selectedDomain
};
}
10.3 parseBoundDomains
hostInfo.domains 可能是 JSON 字符串、数组或逗号分隔文本——此函数统一解析成字符串数组。
11. 八步部署流水线
下面按 [01]~[08] 逐步说明:做什么、判断条件、关键代码。
[01] 初始化部署上下文
干什么:
get_host_info拿主机名、端口、数据库信息、domainsget_user_info拿用户名、邮箱(后面发邮件)requestBindDomain弹窗选域名 → 得到appBaseUrl- 算出所有路径变量
派生变量(记住公式):
var siteRootPath = "/www/wwwroot/" + hostInfo.host_name;
var remotePackagePath = siteRootPath + "/" + packageFileName;
var binaryFilePath = siteRootPath + "/" + binaryFileName;
var configFilePath = siteRootPath + "/config/config.yaml";
var goProjectName = sanitizeGoProjectName(hostInfo.host_name);
var servicePort = (hostInfo.port && hostInfo.port > 0) ? hostInfo.port : defaultPort;
[02] 检测并上传部署包
流程图:
check_file_exists(zip) ├─ found=true → 跳过上传 └─ found=false → upload_file → 再 check 一次校验
上传代码:
uploadResult = upload_file({
host_source: "custom",
host_id: hostId,
targetDir: siteRootPath,
fileName: packageFileName,
content_source: "local",
content: localPackagePath // 如 @/jjwk-service-v2.4.8-install.zip
});
必须检查: uploadResult.status === true && uploadResult.progress === 100
[03] 解压并校验二进制
何时解压:
var shouldExtractPackage = !binaryExistsResult.found || (packageUploaded && forceUnzipAfterUpload);
| 情况 | 行为 |
|---|---|
| 二进制不存在 | 解压 |
| 刚上传了新包且 forceUnzipAfterUpload=true | 强制覆盖解压 |
| 二进制已存在且没传新包 | 跳过 |
解压后:check_file_exists 确认二进制存在 → set_file_access 设为 www:755。
[04] 检查并初始化数据库
database_exists(dbName) ├─ value=true → 跳过创建,从 hostInfo 读 db_name/db_account/db_password └─ value=false → create_database → 再 database_exists 校验
库名默认 sanitizeDbName(host_name)。无密码则 randomDbPassword(16)。
[05] 更新 config.yaml
replace_config_content({
format: "yaml",
host_id: hostId,
filePath: configFilePath,
replacements: {
"database.host": "127.0.0.1",
"database.port": "3306",
"database.username": databaseUser,
"database.dbname": databaseName,
"database.password": databasePassword,
"app.base_url": appBaseUrl,
"app.port": servicePort
}
});
注意: 键必须是 yaml 里的点分路径;任一键在文件里不存在 → 整步失败。
[06] 检查并放行端口
port_rule_exists(port) ├─ exists=true → 跳过 └─ exists=false → set_port_rule → delay(2 秒)
create_go_project 里可设 release_firewall: true,但示例脚本在 [06] 单独做,且创建项目时设 release_firewall: false 避免重复。
[07] 注册并启动 Go 项目
见 第 12、13 节。
[08] 发送部署完成通知
有邮箱则 send_email(异步,不阻塞);无邮箱打日志跳过。
12. Go 项目查重
为什么不用 get_go_project_detail?
实测易误判「不存在」,导致跳过 create_go_project,后面 start_go_project 又失败。
正确做法: 只用 get_go_project_list,封装 findGoProject:
- 用项目名、主机名、sanitize 后的主机名分别搜索
- 每条列表项用三种规则匹配:路径相同 / 项目名相同 / 主机名相同
- 搜不到再全量拉 1000 条扫描
创建后用 verifyGoProjectExists:最多重试 5 次,每次间隔 2 秒,等宝塔列表刷出来。
13. 启动失败自动重建
var startGoProjectResult = start_go_project({ projectName: activeProjectName });
if (!startGoProjectResult || startGoProjectResult.status !== true) {
// 启动失败 → 重新 create_go_project → verify → 再 start
create_go_project({ /* 同参数 */ });
verifyGoProjectExists(...);
startGoProjectResult = start_go_project({ projectName: activeProjectName });
}
assertActionSuccess(startGoProjectResult, "start_go_project");
另外: bind_user_domains_local 在启动前执行,把域名写入本系统主机记录(与宝塔绑定独立)。
14. 邮件通知模板
send_email({
to: userProfile.email,
subject: "主机搭建通知 - " + hostInfo.host_name,
body: "<p>主机 <strong>" + hostInfo.host_name + "</strong> 工作流执行完成。</p>" +
"<ul>" +
"<li>部署包: " + (packageExistsResult.found ? "已存在(跳过上传)" : "已上传") + "</li>" +
"<li>二进制: " + (/* 是否重新解压 */) + "</li>" +
"<li>数据库: " + databaseName + (databaseCreated ? "(新建)" : "(已存在)") + "</li>" +
"<li>站点域名: " + bindDomain + "(" + appBaseUrl + ")</li>" +
"<li>Go 项目: " + activeProjectName + "(端口 " + servicePort + ")</li>" +
"</ul>"
});
send_email 提交后立即继续,不等发信结果。
15. 自定义节点
路径:自定义节点 → 新建
15.1 必填项
| 字段 | 说明 |
|---|---|
| action_key | 函数名,全局唯一,如 sync_user_quota |
| 入参 Schema | 工作流里调用时要传哪些键 |
| 出参 Schema | 文档用途 |
| 执行脚本 | Goja JS |
15.2 脚本内置对象
// ctx.params — 调用方传入的参数
// ctx.host_id — 主机 ID
// ctx.base_url — 有 host_id 时为宝塔面板地址,否则管理端地址
// ctx.api_path — 节点配置的默认路径
// ctx.api_method — 默认 HTTP 方法
// ctx.admin_token / ctx.user_token — 按场景注入
15.3 request / handle_response 模板
var resp = request({
url: ctx.api_path,
method: ctx.api_method || "POST",
headers: { "Content-Type": "application/json" },
body: ctx.params,
timeout: ctx.timeout_sec || 30
});
return handle_response(resp, function(json) {
if (json.http_status >= 400) {
return { status: false, code: json.http_status, message: json.msg || "HTTP 失败" };
}
if (json.code !== undefined && json.code !== 200) {
return { status: false, code: json.code, message: json.msg || "业务失败" };
}
return { status: true, code: 200, data: json.data !== undefined ? json.data : json };
});
15.4 在工作流里调用
var r = sync_user_quota({ host_id: hostId, quota_mb: 1024 });
if (!r.status) throw new Error("同步失败");
启用节点后,左侧操作参考会出现该函数。
16. 发布、上架、挂到用户主机
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 编辑器保存草稿 | 脚本入库 |
| 2 | 发布 | status=已启用 |
| 3 | 列表页「上架」 | 用户端可见 |
| 4 | 商品/快捷入口配置关联工作流 | 用户主机详情出现按钮 |
| 5 | 用户点击 | 自动注入 host_id,host_source 用 auto |
导入导出: 列表页可导出 JSON(含 script),换环境导入。
17. 执行结果面板
| 区块 | 看什么 |
|---|---|
| 执行状态 | 成功/失败/执行中 |
| 步骤明细 | 每步 action_key、参数、返回值、耗时 |
| 控制台日志 | console.log / logInfo / logDetail |
| 上传进度 | upload_file 实时百分比 |
步骤为空?说明脚本里没调用任何节点函数(纯 console.log 不会出现步骤)。
18. 故障排查矩阵
| 现象 | 原因 | 处理 |
|---|---|---|
| 本地文件不存在 | zip 不在 doc 目录 | 放到 doc,路径写 @/xxx.zip |
| host_name 为空 | host_id 无效或未填 | 检查执行主机 ID |
| 某步直接中断 | 节点抛错 | 看步骤明细里的 error / bt_msg |
| database 判断错了 | 用了 status 而非 value | 改用 isDatabaseExists |
| 文件「不存在」判断错 | 用了 exists 而非 found | 改用 result.found |
| Go 项目创建后找不到 | 宝塔列表延迟 | 用 verifyGoProjectExists 重试 |
| 启动失败 | 项目配置过期 | 示例脚本会自动 recreate |
| replace_config 失败 | yaml 里没有那个键 | 核对 config 文件结构 |
| 弹窗后没继续 | 没点确定 | 用户需提交;取消走 cancelled 分支 |
| 脚本语法报错 | Monaco 红线 | 修完再运行;可看 issue 数量 |
19. 完整配置区 + 最小可运行模板
19.1 配置区(改这里就够)
const hostId = ""; // 或靠编辑器注入
const localPackagePath = "@/your-app-install.zip";
const packageFileName = "your-app-install.zip";
const binaryFileName = "your-app";
const defaultPort = 9001;
const forceUnzipAfterUpload = true;
19.2 幂等设计检查清单
- 上传前 check_file_exists
- 上传后再 check 一次
- 解压前判断二进制 + force 标志
- 建库前 database_exists
- 建库后再 exists 校验
- 端口 port_rule_exists
- Go 项目 findGoProject 查重
- 创建后 verifyGoProjectExists
- 启动失败 recreate 分支
19.3 建议学习顺序
- 跑通 第 6 节 检查文件
- 加
upload_file上传一个小 txt - 加
request_user_input弹窗 - 复制完整示例脚本,只改配置区
- 对用户主机上架测试
20. 附录:常用内置节点速查
文件
get_dir_new create_dir create_file delete_file get_file_body save_file_body move_file zip_files unzip_files set_file_access upload_file check_file_exists replace_config_content
数据库
create_database database_exists delete_database get_database_info
Go 项目
get_go_project_list get_go_project_detail create_go_project start_go_project stop_go_project delete_go_project add_go_project_domain modify_go_project
防火墙
get_port_rules port_rule_exists set_port_rule remove_port_rule
工具
get_host_info get_user_info bind_user_domains_local request_user_input end_workflow delay send_email exec_sql(仅管理端)
结语
自定义工作流 = JavaScript + 宝塔 API + 一点点幂等思维。系统已提供 960 行注释完整的「一键部署小氢云脚本」作为范本:配置区改常量、工具函数原样复制、八步流程按需删减,就是你的生产脚本。
管理端 工作流 → 使用教程 还有可复制的短示例;遇到参数不确定,左侧 操作参考 → 点函数名看详情。
现在就去: 新建工作流 → 粘贴第 6 节代码 → 填主机 ID → 运行。