小储云主机:工作流完全指南

admin
67
2026-06-01

自定义工作流完全指南:从零到一键部署

本文档级别:嚼碎了喂嘴里。假设你从未写过脚本,按顺序读、按顺序做,就能写出和系统内置「一键部署小氢云脚本」同等级的自动化流水线。


目录

  1. 30 秒搞懂:这到底是什么
  2. 管理端:第一次打开编辑器(逐步点击)
  3. 脚本运行的底层机制(知道这些就不踩坑)
  4. 命名与参数:一张表记牢
  5. host_id 与 host_source:最容易搞混的点
  6. 第一个能跑的脚本(逐行解释)
  7. 内置节点返回值对照表(必收藏)
  8. 日志工具:直接复制粘贴
  9. 通用工具函数:示例脚本逐函数讲解
  10. 与用户弹窗交互:request_user_input 全解
  11. 八步部署流水线:每一步干什么、为什么、怎么写
  12. Go 项目查重:为什么不用 get_go_project_detail
  13. 启动失败自动重建:容错逻辑
  14. 邮件通知模板
  15. 自定义节点:从创建到在工作流里调用
  16. 发布、上架、挂到用户主机
  17. 执行结果面板:怎么看日志
  18. 故障排查矩阵
  19. 完整配置区 + 最小可运行模板
  20. 附录:常用内置节点速查

1. 30 秒搞懂:这到底是什么

一句话: 在管理后台写一段 JavaScript,里面调用一个个「节点函数」(如上传文件、建数据库),系统自动在宝塔面板上帮你执行。

三个角色:

角色 是什么 举例
工作流 一整段 JS 脚本 + 名称/状态 「一键部署小氢云」
内置节点 系统写好的函数,内部调宝塔 API upload_file({...})
自定义节点 你自己扩展的函数 my_api_call({...})

执行时发生什么:

  1. 你点「运行」或用户在主机详情点快捷按钮
  2. 服务端用 Goja 引擎跑你的 JS
  3. 每调用一次 xxx({...}),记为一步,结果推送到前端
  4. console.log 的内容出现在「控制台日志」
  5. 某步失败 → 立刻中断,后面不再跑(除非你自己用 if 吃掉错误)

2. 管理端:第一次打开编辑器(逐步点击)

2.1 进入路径

左侧菜单 → 工作流管理 → 右上角「新建工作流」 或直接访问:/workflow/editor

2.2 界面分区(从左到右)

区域 作用
操作参考(左栏) 按分类列出所有节点;搜索、展开详情、点「插入」
Monaco 编辑器(中间) 写脚本;支持语法检查、Ctrl+S 保存、缩放
顶部工具栏 工作流名称、描述、执行主机 ID、保存/发布/运行

2.3 第一次运行(逐步)

  1. 名称填:测试检查文件
  2. 中间编辑器全选删除,粘贴 第 6 节 的代码
  3. 顶部 执行主机 ID 填一个真实主机 ID(在主机列表可查)
  4. 运行(无需先保存,会直接跑编辑器里的内容)
  5. 弹出 执行结果 抽屉:
    • 上方:成功/失败
    • 步骤明细:每一步调了哪个函数、参数、返回值
    • 控制台日志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 时:

  1. 后端保存当前已完成的步骤快照
  2. 向前端推送「等待输入」
  3. 用户填完点确定 → 调用 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.foundtrue=找到,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_databasereplace_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] 初始化部署上下文

干什么:

  1. get_host_info 拿主机名、端口、数据库信息、domains
  2. get_user_info 拿用户名、邮箱(后面发邮件)
  3. requestBindDomain 弹窗选域名 → 得到 appBaseUrl
  4. 算出所有路径变量

派生变量(记住公式):

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

  1. 用项目名、主机名、sanitize 后的主机名分别搜索
  2. 每条列表项用三种规则匹配:路径相同 / 项目名相同 / 主机名相同
  3. 搜不到再全量拉 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 建议学习顺序

  1. 跑通 第 6 节 检查文件
  2. upload_file 上传一个小 txt
  3. request_user_input 弹窗
  4. 复制完整示例脚本,只改配置区
  5. 对用户主机上架测试

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 → 运行。