从零搭一套「手机派活」系统:完整命令与踩坑手册

目标:手机派活 → 服务器排队 → 家里的电脑执行 → 结果回手机。
全程零外部依赖,一台服务器 + 一台电脑,半小时到一小时能跑通。
本文所有命令都可直接复制,把尖括号占位符换成你自己的即可。


第 0 步:先确认你要付出什么

项要求说明
服务器1 核 512MB 起,Node ≥ 16常年在线,有公网 IP
家里电脑Node ≥ 16,Windows / macOS / Linux 都行不用常开,开了就自动接任务
手机任意现代浏览器加到主屏幕即可,不用装 App
成本服务器月费 + 少量 API 费用任务可以完全不调 API

架构一句话:家里那台机器主动连出去领任务,所以不需要公网 IP、不需要内网穿透、不需要动路由器。

第 1 步:服务器准备

1.1 连接方式:用密钥,不用密码

在本机生成密钥对(Windows 的 PowerShell 里同样适用):

ssh-keygen -t ed25519 -f ~/.ssh/pocket -N ""

坑 1:-N "" 在 PowerShell 里可能被解析成字面量引号,生成带密码短语的密钥。生成后验证一句:

ssh-keygen -y -f ~/.ssh/pocket

能直接打印出公钥就是没加密;提示输入 passphrase 就是加密了,重新生成。
把公钥装到服务器上。注意用户名不一定是 root,很多云厂商的默认用户是 ubuntu、centos、lighthouse 之类:

# 在服务器上执行(通过云厂商控制台的网页终端登录)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' '<把你的公钥整行粘在这里>' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
grep -c 'ssh-ed25519' ~/.ssh/authorized_keys

最后一行返回 1 就成功了。

坑 2:如果公开密钥要装到 /root/.ssh/,而你不是 root,会报 Permission denied。用 ~/.ssh(自己的家目录)最省事。真要装 root,得 sudo mkdir -p /root/.ssh 并 sudo tee 追加。

1.2 验证免密登录

在本机执行(注意用户名换成服务器实际用户):

ssh -i ~/.ssh/pocket <用户名>@<服务器IP> "whoami"

返回用户名,接着传文件:

scp -i ~/.ssh/pocket <本地文件> <用户名>@<服务器IP>:~/ 

坑 3:第一次连接会问 Are you sure you want to continue connecting,输 yes 即可。如果之后同一台机器指纹变了,不要随手输 yes,先查清楚是不是重装或被劫持。


第 2 步:装 Node(这一步坑最多)

2.1 先看系统版本

cat /etc/os-release | head -3
node -v 2>/dev/null || echo "无 node"

2.2 分情况处理

情况 A:Ubuntu / Debian,且 glibc ≥ 2.28(主流新系统)

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v

情况 B:CentOS 7 或任何 glibc 2.17 的老系统
官方 Node 18/20 二进制要求 glibc ≥ 2.28,直接装会报 GLIBC_2.28 not found。两条路:

# 路线 1:用官方提供的 glibc-217 兼容构建
curl -fsSL https://unofficial-builds.nodejs.org/download/release/index.json \
  | tr ',' '\n' | grep -o '"version":"v20\.[0-9]*\.[0-9]*"' | head -1
# 拿到版本号后(假设是 v20.x.y):
curl -fSL -o /tmp/node.tar.gz \
  https://unofficial-builds.nodejs.org/download/release/v20.x.y/node-v20.x.y-linux-x64-glibc-217.tar.gz
sudo rm -rf /opt/nodejs && sudo mkdir -p /opt/nodejs
sudo tar -xzf /tmp/node.tar.gz -C /opt/nodejs --strip-components=1
sudo ln -sf /opt/nodejs/bin/node /usr/local/bin/node
sudo ln -sf /opt/nodejs/bin/npm /usr/local/bin/npm
/opt/nodejs/bin/node -v
# 路线 2:退回 Node 16(最后支持 glibc 2.17 的大版本)
curl -fSL -o /tmp/node16.tar.gz https://nodejs.org/dist/v16.20.2/node-v16.20.2-linux-x64.tar.gz
sudo rm -rf /opt/nodejs && sudo mkdir -p /opt/nodejs
sudo tar -xzf /tmp/node16.tar.gz -C /opt/nodejs --strip-components=1
sudo ln -sf /opt/nodejs/bin/node /usr/local/bin/node
/opt/nodejs/bin/node -v

坑 4(最隐蔽):sudo 会重置 PATH(secure_path 里通常没有 /usr/local/bin),所以软链接建好了,sudo node -v 依然报 command not found。
解法:① 别依赖 PATH,用绝对路径 /opt/nodejs/bin/node;② 在脚本开头加 export PATH="/usr/local/bin:$PATH"。

坑 5:如果代码用了 fetch(Node 18+ 才有),退回 Node 16 就跑不起来。要么自己写三十行 http/https 客户端,要么装 node-fetch。前者更干净。


第 3 步:部署控制面

3.1 放文件

mkdir -p ~/pocket-agent/public
# 把 server.mjs 传到 ~/pocket-agent/,把前端 index.html 传到 ~/pocket-agent/public/

3.2 生成配置(令牌自己生,别手编)

cd ~/pocket-agent
UI=$(/opt/nodejs/bin/node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))')
WK=$(/opt/nodejs/bin/node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))')
cat > config.json <<JSON
{
  "port": 8787,
  "bind": "0.0.0.0",
  "uiToken": "$UI",
  "workerToken": "$WK",
  "presets": [
    { "label": "示例脚本", "script": "demo" }
  ],
  "ntfy": null
}
JSON
chmod 600 config.json
echo "uiToken=$UI"; echo "workerToken=$WK"

把两个令牌抄下来,关掉终端就找不到了(虽然还能 cat config.json)。

3.3 装成系统服务

sudo useradd --system --create-home --shell /sbin/nologin pocket || true
sudo mkdir -p /opt/pocket-agent
sudo cp ~/pocket-agent/server.mjs /opt/pocket-agent/
sudo cp -r ~/pocket-agent/public /opt/pocket-agent/
sudo cp ~/pocket-agent/config.json /opt/pocket-agent/
sudo chown -R pocket:pocket /opt/pocket-agent
sudo chmod 600 /opt/pocket-agent/config.json
sudo tee /etc/systemd/system/pocket-agent.service >/dev/null <<'UNIT'
[Unit]
Description=Pocket Agent control plane
After=network.target
[Service]
Type=simple
User=pocket
WorkingDirectory=/opt/pocket-agent
ExecStart=/opt/nodejs/bin/node /opt/pocket-agent/server.mjs
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now pocket-agent
sudo systemctl is-active pocket-agent

坑 6:ExecStart 里必须写绝对路径的 node,别写 node。原因同坑 4,systemd 的 PATH 和你的 shell 不一样。

3.4 本机自检

curl -s http://127.0.0.1:8787/api/state -H "x-token: <uiToken>"

返回一段 JSON(含 "workerOnline":false)就说明服务起来了。

坑 7:如果返回 Connection refused,先看日志:

sudo journalctl -u pocket-agent -n 30 --no-pager

最常见的是 EADDRINUSE(端口被占用)或配置写错(少个逗号)。

3.5 放行端口(两处,缺一不可)

第一处:云厂商控制台的安全组/防火墙
进控制台 → 找到你的实例 → 防火墙 → 添加规则:

协议:TCP    端口:8787    来源:0.0.0.0/0    策略:允许

第二处:系统自带防火墙(如果开了)

# firewalld
sudo firewall-cmd --permanent --add-port=8787/tcp && sudo firewall-cmd --reload
# ufw
sudo ufw allow 8787/tcp

从本机验证:

curl -s http://<服务器IP>:8787/api/state -H "x-token: <uiToken>"

坑 8:只放行了一处,表现是「服务器上 curl 通,外面 curl 不通」。两处都要查。


第 4 步:部署执行端(家里的电脑)

4.1 配置文件

worker.config.json:

{
  "serverUrl": "http://<服务器IP>:8787",
  "workerToken": "<workerToken>",
  "workspace": "<你的工作目录绝对路径>",
  "pollWaitSec": 25,
  "logPushMs": 1200,
  "timeoutSec": 1800,
  "dsh": { "cmd": "node", "args": ["<Agent 入口 JS 的绝对路径>", "--profile", "headless"] },
  "scripts": {
    "demo": { "label": "示例脚本", "cmd": "node", "args": ["-e", "console.log('hello')"] }
  }
}

4.2 启动

node worker.mjs

看到「worker 启动|服务器 http://…」就说明连上了。

坑 9(Windows 专属):如果配置里写 "cmd": "dsh",会报 spawn dsh ENOENT。因为 Windows 上 dsh 是 .cmd 包装脚本,Node 出于安全考虑不允许无 shell 直接 spawn .cmd。
解法:找到它的 JS 入口(npm root -g 可以查全局包目录),用 node <入口>.js 的方式启动,不要为了省事加 shell: true——那会让任务参数里的特殊字符变成命令注入。
坑 10:任务一直显示「排队中」,先看执行端是否在线:

curl -s http://<服务器IP>:8787/api/state -H "x-token: <uiToken>"

"workerOnline": true 才说明心跳正常。如果启动后二十秒内还是 false,说明心跳间隔太长,应改成启动瞬间先发一次心跳。

4.3 安全设计:白名单必须在执行端

控制端只允许提交「任务类型 + 参数」,真正的命令映射写在执行端的配置里,遇到不认识的名字直接拒绝:

未授权的脚本:xxx

坑 11:把命令映射放在服务器上(比如让网页直接传 {"cmd":"rm -rf /"})是最危险的做法。控制面是公网那一端,安全边界要放在受保护的那一侧。


第 5 步:手机端

5.1 打开并填令牌

浏览器访问 http://<服务器IP>:8787,首次会让你填 uiToken。
也支持一次性导入:http://<服务器IP>:8787/#t=<uiToken>,导入后地址栏会自动清掉。

5.2 加到主屏幕

  • Chrome(Android):右上角菜单 →「添加到主屏幕」
  • Safari(iOS):分享 →「添加到主屏幕」

坑 12:用 HTTP 访问时,Android Chrome 只能创建「快捷方式」,不能算真正的 PWA(可安装 PWA 需要 HTTPS)。功能不受影响,只是图标角标会有个小标识。

5.3 验证闭环

派一个脚本任务,等几秒:

curl -s "http://<服务器IP>:8787/api/tasks?limit=1" -H "x-token: <uiToken>"

"status":"done" 且 result 有内容,就算通了。

第 6 步:暂存区与本地同步(可选,但强烈建议)

用途:白天在手机上记的想法,先落在服务器,晚上开机再搬回家,云端不留副本。

6.1 服务器端加接口

需要实现四个:写、列表、读、确认删除。所有路径都要做越界检查:

path.resolve(base, 用户传入的路径) 必须仍以 base 开头,否则返回 400

验证方式:

curl -s "http://127.0.0.1:8787/api/spool/file?p=../../etc/passwd" -H "x-token: <uiToken>"
# 期望 400,而不是把 /etc/passwd 吐出来

6.2 建立暂存目录

sudo mkdir -p /srv/cloud/spool
sudo chown -R pocket:pocket /srv/cloud

6.3 本地同步脚本(四步协议)

# 1. 拿清单
Invoke-RestMethod "http://<服务器IP>:8787/api/spool" -Headers @{ 'x-token' = '<uiToken>' }
# 2. 逐个下载  GET /api/spool/file?p=<相对路径>
# 3. 按规则写进本地目录
# 4. 只把「确实写成功」的路径提交到 POST /api/spool/ack

坑 13:不要用 git 同步隐私内容。git 的历史是永久的——删掉文件再 commit,内容还躺在 .git 里。对日记、想法这类东西,这等于没删。
必须「拉取成功才删」,服务器上物理删除。
坑 14:同步脚本要先写文件、再 ack。顺序反了(先删后写)会在网络中断时丢数据。


第 7 步:定时收集(可选)

抓几个 RSS 源,给新文章配一句话概括,双写:一份给手机看,一份进周报。

7.1 配置

{
  "deepseek": { "apiKey": "", "model": "deepseek-chat" },
  "feeds": [
    { "name": "a", "label": "某人博客", "url": "https://example.com/feed/" }
  ]
}

apiKey 留空也能跑——摘要会退化成「原文前 60 字」。

7.2 定时器

sudo tee /etc/systemd/system/collect.service >/dev/null <<'UNIT'
[Unit]
Description=RSS collector
After=network-online.target
[Service]
Type=oneshot
User=pocket
ExecStart=/opt/nodejs/bin/node /srv/cloud/collect.mjs
UNIT
sudo tee /etc/systemd/system/collect.timer >/dev/null <<'UNIT'
[Unit]
Description=Run collector every 6 hours
[Timer]
OnCalendar=*-*-* 00/6:17:00
Persistent=true
Unit=collect.service
[Install]
WantedBy=timers.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now collect.timer
systemctl list-timers collect.timer --no-pager

坑 15:首次运行只建基线,不要倒灌历史。否则订阅的瞬间会收到几十篇旧文,你根本不会看。
实现方式:判断「状态文件不存在」时,只记录已有条目为已读,不产出任何内容。
坑 16:RSS 的 guid 才是去重依据,不是标题也不是链接。用 sha1(feedName + guid) 存成集合,最省空间。


第 8 步:开机自启

8.1 服务器

已经在第 3 步用 systemctl enable 处理了。收集器是 enable --now xxx.timer。

8.2 家里的电脑

Windows:Win + R 输入 shell:startup,把启动脚本的快捷方式丢进去。
或者用管理员 PowerShell 建计划任务:

schtasks /create /tn "pocket-agent-worker" /tr "<启动脚本绝对路径>" /sc onlogon /rl highest

坑 17:/rl highest 需要管理员权限,普通窗口会报「拒绝访问」。没有管理员权限就用 shell:startup,效果一样。
Linux / macOS:用 systemd --user 或 launchd,或者最土的办法——tmux new -d -s worker 'node worker.mjs'。


第 9 步:日常运维命令

# 控制面
systemctl status pocket-agent
journalctl -u pocket-agent -f
sudo systemctl restart pocket-agent
# 收集器
systemctl list-timers collect.timer
sudo systemctl start collect.service
journalctl -u collect -n 30 --no-pager
# 手动跑一次收集(不写文件,只看结果)
sudo -u pocket /opt/nodejs/bin/node /srv/cloud/collect.mjs --force --dry
# 看暂存区
ls -R /srv/cloud/spool
ls -l /srv/cloud/state/
# 本地执行端
node worker.mjs
# 手动同步
powershell -ExecutionPolicy Bypass -File sync-cloud.ps1 -Token <uiToken>

第 10 步:故障排查表

症状最可能的原因排查命令
外面访问不了云控制台防火墙没放行curl http://<IP>:<端口>/api/state 本机对比
本机也访问不了服务没起来systemctl status xxx
EADDRINUSE端口被老进程占着`ss -lntp \grep <端口>`
node: command not foundsudo 的 PATH 里没有安装目录which node vs sudo which node
GLIBC_2.28 not found老系统 + 新二进制ldd --version 看 glibc
spawn xxx ENOENTWindows 上 .cmd 不能直接 spawn改用 node <入口>.js
任务一直「排队中」执行端没连上看 /api/state 的 workerOnline
任务「完成」但没输出子进程输出没被捕获检查是否重定向到文件/管道
401令牌错或没带检查请求头 x-token
403 需要执行端令牌用手机令牌调了执行端接口换 workerToken
手机端一直转圈前端缓存了旧页面强制刷新或清站点数据
中文乱码响应头缺 charset=utf-8检查 content-type

第 11 步:安全检查清单

上线前逐条确认:

  • [ ] 令牌是随机生成的(16 字节以上),不是手工编的
  • [ ] config.json 权限 600
  • [ ] 手机令牌和执行端令牌权限不同(手机不能调「领任务」「回传结果」)
  • [ ] 执行端的命令映射是白名单,不是「执行我发来的字符串」
  • [ ] 有超时保护,任务不会永久挂着
  • [ ] 暂存接口做了路径穿越检查
  • [ ] 日志里没有打印令牌
  • [ ] 服务器上跑的进程不是 root
  • [ ] 想清楚 HTTPS 什么时候上(明文传输令牌的窗口期尽量短)
  • [ ] 想清楚哪些数据不能上云(这条比前面九条都重要)

最后:这套东西的价值在哪

它不是「AI 助手」,也不是「自动化平台」。它解决的是一个很具体的不对称:

你有想法的时间(白天、路上、床上),和你机器能干活的时间(晚上),是错开的。
传统做法是人迁就机器——想起来记在备忘录,晚上再翻出来干。这套东西做的是反过来:让机器迁就人的节奏。

所以核心不是模型多强,而是三条:队列(派活和干活解耦)、暂存(记下来不等设备)、搬运(数据落在该在的地方)。

模型只是其中最不重要的一环。先把管道修通,再谈智能。

本文基于一次真实搭建整理,所有踩坑均为实际遇到。