线上为 1 核 1.9G 机器,放弃 Docker:Node 直跑 + systemd 守护, 前端本地构建后由服务端静态托管,HTTPS 与反代由 aaPanel 的 nginx 管理。 部署脚本支持 web/server/all 三种目标,含本地类型检查与单测、 暂存目录原子换入、健康检查失败自动回滚源码、本地与远端源码树指纹比对 及公网校验。首次部署与一次性配置写在 docs/DEPLOY.md。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
143 lines
8.0 KiB
Bash
Executable File
143 lines
8.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# proxy-station 部署脚本:把前端产物/后端源码推到 LA 服务器(Node 直跑 + systemd,nginx 由 aaPanel 管)
|
||
#
|
||
# deploy.sh [web|server|all] [--skip-checks] [--skip-build] [--host <ssh别名>]
|
||
#
|
||
# web 只构建并替换前端静态产物(服务端按请求读盘,不需要重启)
|
||
# server 只推送 shared/ 与 server/ 源码,安装生产依赖并重启 systemd 服务
|
||
# all 两者都做(默认)
|
||
#
|
||
# 数据库、/etc/proxy-station/env、nginx 站点与证书一律不碰——那些是一次性配置,见 docs/DEPLOY.md。
|
||
set -euo pipefail
|
||
|
||
# 会话注入的 HTTP 代理只供模型 API 使用,ssh/pnpm/curl 一律直连
|
||
unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY NO_PROXY no_proxy
|
||
|
||
HOST="${DEPLOY_HOST:-la}"
|
||
REMOTE_DIR="${DEPLOY_REMOTE_DIR:-/opt/proxy-station}"
|
||
SERVICE="${DEPLOY_SERVICE:-proxy-station}"
|
||
RUN_USER="${DEPLOY_RUN_USER:-proxy-station}"
|
||
PUBLIC_URL="${DEPLOY_PUBLIC_URL:-https://ps.la.njcqtechaicoding.com}"
|
||
LOCAL_PORT="${DEPLOY_LOCAL_PORT:-3000}"
|
||
|
||
TARGET=all
|
||
SKIP_CHECKS=0
|
||
SKIP_BUILD=0
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
web|server|all) TARGET="$1" ;;
|
||
--skip-checks) SKIP_CHECKS=1 ;;
|
||
--skip-build) SKIP_BUILD=1 ;;
|
||
--host) shift; HOST="$1" ;;
|
||
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
||
*) echo "未知参数:$1(可用:web|server|all --skip-checks --skip-build --host <别名>)" >&2; exit 2 ;;
|
||
esac
|
||
shift
|
||
done
|
||
|
||
ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || { cd "$(dirname "$0")/../../../.." && pwd; })"
|
||
cd "$ROOT"
|
||
|
||
SSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 "$HOST")
|
||
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
|
||
fail() { printf '\033[1;31m!! %s\033[0m\n' "$*" >&2; exit 1; }
|
||
|
||
# 本地与远端各算一遍「源码树指纹」:文件按路径排序后逐个取 sha256,再对哈希列表取 sha256。
|
||
# 部署完两边一致就能确定推上去的就是本地这份,而不是靠「命令没报错」来猜。
|
||
hash_cmd_local() { command -v sha256sum >/dev/null && echo sha256sum || echo 'shasum -a 256'; }
|
||
tree_hash_local() {
|
||
local h; h="$(hash_cmd_local)"
|
||
find "$@" -type f ! -name '*.test.ts' | LC_ALL=C sort | xargs $h | awk '{print $1}' | $h | awk '{print $1}'
|
||
}
|
||
tree_hash_remote() {
|
||
"${SSH[@]}" "cd '$REMOTE_DIR' && find $* -type f ! -name '*.test.ts' | LC_ALL=C sort | xargs sha256sum | awk '{print \$1}' | sha256sum | awk '{print \$1}'"
|
||
}
|
||
|
||
# ---------- 预检 ----------
|
||
step "预检:ssh 别名 ${HOST}、远端目录、服务状态"
|
||
case "$HOST" in *[[:upper:]]*) fail "ssh 别名请用小写(大写会被当成域名走 fake-IP 解析而连不上)";; esac
|
||
"${SSH[@]}" "test -d '$REMOTE_DIR/server' && test -f /etc/systemd/system/$SERVICE.service" \
|
||
|| fail "远端缺少 $REMOTE_DIR/server 或 systemd 单元,请先按 docs/DEPLOY.md 完成首次部署"
|
||
GIT_REV="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||
GIT_DIRTY="$(git status --porcelain 2>/dev/null | grep -q . && echo '(工作区有未提交改动)' || true)"
|
||
echo "本地版本:$GIT_REV $GIT_DIRTY"
|
||
echo "目标:$TARGET → $HOST:$REMOTE_DIR"
|
||
|
||
# ---------- 本地检查与构建 ----------
|
||
if [ "$TARGET" != web ] && [ "$SKIP_CHECKS" = 0 ]; then
|
||
step "本地类型检查与单测(--skip-checks 可跳过)"
|
||
pnpm -r typecheck >/dev/null || fail "typecheck 失败,先修再部署"
|
||
pnpm test 2>&1 | tail -4
|
||
fi
|
||
if [ "$TARGET" != server ]; then
|
||
if [ "$SKIP_BUILD" = 0 ]; then
|
||
step "构建前端"
|
||
pnpm --filter @proxy-station/web build 2>&1 | tail -3
|
||
else
|
||
step "跳过构建,使用现有 web/dist"
|
||
fi
|
||
test -f web/dist/index.html || fail "web/dist/index.html 不存在"
|
||
fi
|
||
|
||
# ---------- 推送 ----------
|
||
# macOS 的 bsdtar 会把文件 xattr 写成扩展头,远端 GNU tar 每个文件刷一行「unknown extended header」;两端各关一次
|
||
TAR_EXCLUDES=(--no-xattrs --exclude='node_modules' --exclude='*.test.ts' --exclude='.DS_Store' --exclude='data' --exclude='*.db*' --exclude='.env')
|
||
export COPYFILE_DISABLE=1
|
||
UNTAR='tar xzf - --warning=no-unknown-keyword'
|
||
|
||
if [ "$TARGET" != server ]; then
|
||
step "推送前端产物 → $REMOTE_DIR/web/dist(原子替换,旧版保留为 dist.old)"
|
||
tar czf - "${TAR_EXCLUDES[@]}" web/dist | "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; rm -rf .staging-web; mkdir .staging-web; $UNTAR -C .staging-web;
|
||
rm -rf web/dist.old; [ -d web/dist ] && mv web/dist web/dist.old; mv .staging-web/web/dist web/dist; rm -rf .staging-web;
|
||
chown -R $RUN_USER:$RUN_USER web/dist"
|
||
fi
|
||
|
||
if [ "$TARGET" != web ]; then
|
||
step "推送后端源码与锁文件 → ${REMOTE_DIR}(旧源码保留为 src.old 以便回滚)"
|
||
tar czf - "${TAR_EXCLUDES[@]}" package.json pnpm-workspace.yaml pnpm-lock.yaml \
|
||
shared/package.json shared/tsconfig.json shared/src \
|
||
server/package.json server/tsconfig.json server/src \
|
||
web/package.json \
|
||
| "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; rm -rf .staging; mkdir .staging; $UNTAR -C .staging;
|
||
for d in server/src shared/src; do rm -rf \"\$d.old\"; [ -d \"\$d\" ] && mv \"\$d\" \"\$d.old\"; mv \".staging/\$d\" \"\$d\"; done;
|
||
cp .staging/package.json .staging/pnpm-workspace.yaml .staging/pnpm-lock.yaml .;
|
||
cp .staging/shared/package.json .staging/shared/tsconfig.json shared/;
|
||
cp .staging/server/package.json .staging/server/tsconfig.json server/;
|
||
cp .staging/web/package.json web/; rm -rf .staging"
|
||
|
||
step "远端安装生产依赖(frozen lockfile)"
|
||
"${SSH[@]}" "cd '$REMOTE_DIR' && CI=true pnpm install --frozen-lockfile --prod --filter '@proxy-station/server...' 2>&1 | tail -3 && chown -R $RUN_USER:$RUN_USER '$REMOTE_DIR'" \
|
||
|| fail "pnpm install 失败。锁文件与 package.json 不一致时先在本地 pnpm install 并提交锁文件"
|
||
|
||
step "重启 $SERVICE 并等待健康检查"
|
||
"${SSH[@]}" "systemctl restart $SERVICE"
|
||
if ! "${SSH[@]}" "for i in \$(seq 1 20); do curl -sf -m 2 http://127.0.0.1:$LOCAL_PORT/api/health >/dev/null && exit 0; sleep 1; done; exit 1"; then
|
||
echo "健康检查 20 秒未通过,最近日志:"
|
||
"${SSH[@]}" "journalctl -u $SERVICE -n 30 --no-pager -o cat" || true
|
||
step "回滚到上一版源码"
|
||
"${SSH[@]}" "set -e; cd '$REMOTE_DIR'; for d in server/src shared/src; do [ -d \"\$d.old\" ] && rm -rf \"\$d\" && mv \"\$d.old\" \"\$d\"; done; chown -R $RUN_USER:$RUN_USER .; systemctl restart $SERVICE"
|
||
fail "已回滚到旧源码并重启(锁文件与依赖未回滚)。请在本地复现后再部署"
|
||
fi
|
||
fi
|
||
|
||
# ---------- 校验 ----------
|
||
step "校验"
|
||
"${SSH[@]}" "printf 'rev=%s target=%s at=%s\n' '$GIT_REV' '$TARGET' \"\$(date -Is)\" > '$REMOTE_DIR/.deploy-stamp'"
|
||
OK=1
|
||
if [ "$TARGET" != server ]; then
|
||
L="$(tree_hash_local web/dist)"; R="$(tree_hash_remote web/dist)"
|
||
[ "$L" = "$R" ] && echo "前端产物指纹一致:${L:0:12}" || { echo "前端产物指纹不一致:本地 ${L:0:12} 远端 ${R:0:12}"; OK=0; }
|
||
ASSET="$(grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' web/dist/index.html | head -1)"
|
||
if curl -sf -m 10 "$PUBLIC_URL/" | grep -q "$ASSET"; then echo "公网首页已引用新入口脚本 $ASSET"; else echo "公网首页未引用 ${ASSET}(缓存或部署失败)"; OK=0; fi
|
||
fi
|
||
if [ "$TARGET" != web ]; then
|
||
L="$(tree_hash_local server/src shared/src)"; R="$(tree_hash_remote server/src shared/src)"
|
||
[ "$L" = "$R" ] && echo "后端源码指纹一致:${L:0:12}" || { echo "后端源码指纹不一致:本地 ${L:0:12} 远端 ${R:0:12}"; OK=0; }
|
||
"${SSH[@]}" "systemctl is-active --quiet $SERVICE" && echo "systemd 服务 active" || { echo "systemd 服务未运行"; OK=0; }
|
||
fi
|
||
if curl -sf -m 10 "$PUBLIC_URL/api/health" | grep -q '"ok":true'; then echo "公网健康检查通过:$PUBLIC_URL/api/health"; else echo "公网健康检查失败"; OK=0; fi
|
||
CODE="$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$PUBLIC_URL/api/nodes")"
|
||
[ "$CODE" = 401 ] && echo "管理接口未带 token 返回 401,鉴权正常" || { echo "管理接口未带 token 返回 ${CODE},预期 401"; OK=0; }
|
||
|
||
[ "$OK" = 1 ] && step "部署完成:$TARGET @ $GIT_REV → $PUBLIC_URL" || fail "部署校验有未通过项,见上"
|