添加一键部署脚本 deploy.sh(构建/上传/DB/nginx/SSL/PM2 全自动,支持首次部署与更新)

This commit is contained in:
freedakgmail
2026-06-13 01:17:51 +08:00
parent c670b9e454
commit 2537e5beef
3 changed files with 302 additions and 0 deletions
+1
View File
@@ -6,3 +6,4 @@ coverage/
*.log
.env
.env.local
deploy.env
+32
View File
@@ -0,0 +1,32 @@
# 部署配置模板:复制为 deploy.env 并按需修改(deploy.env 已被 .gitignore 忽略,勿提交)。
# 所有项均可省略,省略时使用 deploy.sh 内的默认值。
# ---- 目标服务器 ----
SERVER_HOST=152.136.182.184
SERVER_USER=ubuntu
# 服务器登录密码(需要 sshpass)。留空则使用 SSH 密钥免密登录(推荐)。
SERVER_PASS=
# 绑定域名(需提前把 DNS A 记录解析到 SERVER_HOST
DOMAIN=pm.hr8ai.top
# ---- 应用 ----
APP_DIR=/opt/riskagent
APP_NAME=riskagent
BACKEND_PORT=3005
# ---- 数据库(仅 --first-run 使用)----
DB_NAME=riskagent
DB_USER=riskagent
# 留空则首次部署自动生成随机密码并写入服务器 .env
DB_PASS=
PG_SUPER=postgres
# ---- 运行环境(首次写入服务器 .env,之后不覆盖)----
# 留空则自动生成高强度随机值(启用 RBAC)
AUTH_SECRET=
# 通义千问 / DashScope key;留空则关闭 LLM,回退确定性规则
DASHSCOPE_API_KEY=
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL=qwen-plus
LLM_TIMEOUT_MS=15000
TARGET_NET_MARGIN=0.05
Executable
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env bash
#
# 一键部署脚本:外包项目风险评估系统
# ---------------------------------------------------------------------------
# 用法:
# ./deploy.sh # 常规更新:本地构建 → 上传 → 重启(保留服务器 .env)
# ./deploy.sh --first-run # 首次部署:额外执行 建库/装 pgvector/配 nginx/签 SSL
# ./deploy.sh --restart-only # 仅重启后端(不构建不上传)
# ./deploy.sh --logs # 查看后端实时日志
#
# 配置:复制 deploy.env.example 为 deploy.env 并填写(deploy.env 已被 .gitignore 忽略)。
# 也可直接用环境变量覆盖任意配置项。
# ---------------------------------------------------------------------------
set -euo pipefail
cd "$(dirname "$0")"
# ----------------------------- 加载配置 -----------------------------
if [[ -f deploy.env ]]; then
# shellcheck disable=SC1091
source deploy.env
fi
# 目标服务器
SERVER_HOST="${SERVER_HOST:-152.136.182.184}"
SERVER_USER="${SERVER_USER:-ubuntu}"
SERVER_PASS="${SERVER_PASS:-}" # 留空则使用 SSH 密钥免密登录
DOMAIN="${DOMAIN:-pm.hr8ai.top}"
# 应用部署参数
APP_DIR="${APP_DIR:-/opt/riskagent}"
APP_NAME="${APP_NAME:-riskagent}"
BACKEND_PORT="${BACKEND_PORT:-3005}"
# 数据库(首次部署用)
DB_NAME="${DB_NAME:-riskagent}"
DB_USER="${DB_USER:-riskagent}"
DB_PASS="${DB_PASS:-}" # 首次部署留空则自动生成
PG_SUPER="${PG_SUPER:-postgres}" # PostgreSQL 超级用户(用于建库/装扩展)
# 应用环境变量(写入服务器 .env,仅当 .env 不存在时)
AUTH_SECRET="${AUTH_SECRET:-}" # 留空则自动生成
DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-}"
LLM_BASE_URL="${LLM_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}"
LLM_MODEL="${LLM_MODEL:-qwen-plus}"
LLM_TIMEOUT_MS="${LLM_TIMEOUT_MS:-15000}"
TARGET_NET_MARGIN="${TARGET_NET_MARGIN:-0.05}"
# ----------------------------- 工具函数 -----------------------------
c_info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
c_ok() { printf '\033[1;32m[ OK ]\033[0m %s\n' "$*"; }
c_warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
c_err() { printf '\033[1;31m[FAIL]\033[0m %s\n' "$*" >&2; }
# SSH / rsync 封装(自动选择 sshpass 或密钥)
SSH_BASE=(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=20)
if [[ -n "$SERVER_PASS" ]]; then
command -v sshpass >/dev/null || { c_err "需要 sshpassSERVER_PASS 已设置)。安装:brew install sshpass / apt install sshpass"; exit 1; }
SSH=(sshpass -p "$SERVER_PASS" "${SSH_BASE[@]}")
RSYNC_RSH="sshpass -p $SERVER_PASS ssh -o StrictHostKeyChecking=no"
else
SSH=("${SSH_BASE[@]}")
RSYNC_RSH="ssh -o StrictHostKeyChecking=no"
fi
remote() { "${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}" "$@"; }
# 以 sudo 执行远程命令(sshpass 时通过 -S 传入密码)
remote_sudo() {
if [[ -n "$SERVER_PASS" ]]; then
remote "echo '$SERVER_PASS' | sudo -S bash -c $(printf '%q' "$*")"
else
remote "sudo bash -c $(printf '%q' "$*")"
fi
}
# ----------------------------- 子命令 -----------------------------
MODE="update"
case "${1:-}" in
--first-run) MODE="first-run" ;;
--restart-only) MODE="restart" ;;
--logs) MODE="logs" ;;
"") MODE="update" ;;
*) c_err "未知参数: $1"; exit 1 ;;
esac
if [[ "$MODE" == "logs" ]]; then
remote "pm2 logs $APP_NAME --lines 60"
exit 0
fi
if [[ "$MODE" == "restart" ]]; then
c_info "重启后端 $APP_NAME ..."
remote "pm2 restart $APP_NAME --update-env && pm2 save"
remote "sleep 2 && curl -s http://127.0.0.1:${BACKEND_PORT}/api/health"; echo
c_ok "已重启"
exit 0
fi
c_info "目标: ${SERVER_USER}@${SERVER_HOST} 域名: ${DOMAIN} 模式: ${MODE}"
# ----------------------------- 1. 本地构建 -----------------------------
c_info "本地构建后端 (tsc --build) ..."
npx tsc --build
[[ -f dist/server/index.js ]] || { c_err "后端构建产物缺失 dist/server/index.js"; exit 1; }
c_ok "后端构建完成"
c_info "本地构建前端 (vite build) ..."
npx vite build
[[ -f web/dist/index.html ]] || { c_err "前端构建产物缺失 web/dist/index.html"; exit 1; }
c_ok "前端构建完成"
# ----------------------------- 2. 远程目录 -----------------------------
c_info "确保远程目录 ${APP_DIR} ..."
remote_sudo "mkdir -p ${APP_DIR} && chown ${SERVER_USER}:${SERVER_USER} ${APP_DIR}"
# ----------------------------- 3. 首次部署:DB / pgvector -----------------------------
if [[ "$MODE" == "first-run" ]]; then
if [[ -z "$DB_PASS" ]]; then
DB_PASS="$(openssl rand -hex 16)"
c_info "已自动生成数据库密码"
fi
c_info "安装 pgvector 扩展包 ..."
remote_sudo "apt-get install -y postgresql-16-pgvector >/dev/null 2>&1 || apt-get install -y postgresql-$(remote_sudo 'pg_lsclusters -h | awk "{print \$1; exit}"' 2>/dev/null || echo 16)-pgvector" || c_warn "pgvector 安装可能失败,请确认 PG 版本对应包"
c_info "创建数据库角色 / 库 / 扩展(幂等)..."
remote_sudo "sudo -u ${PG_SUPER} psql -v ON_ERROR_STOP=1 -c \"DO \\\$\\\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='${DB_USER}') THEN CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASS}'; END IF; END \\\$\\\$;\""
remote_sudo "sudo -u ${PG_SUPER} psql -tAc \"SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'\" | grep -q 1 || sudo -u ${PG_SUPER} createdb -O ${DB_USER} ${DB_NAME}"
remote_sudo "sudo -u ${PG_SUPER} psql -d ${DB_NAME} -c 'CREATE EXTENSION IF NOT EXISTS vector;'"
c_ok "数据库就绪:${DB_NAME}owner=${DB_USER}, pgvector 已启用)"
fi
# ----------------------------- 4. 上传产物 -----------------------------
c_info "上传后端产物与依赖清单 ..."
rsync -az --delete -e "$RSYNC_RSH" \
dist migrations package.json package-lock.json \
"${SERVER_USER}@${SERVER_HOST}:${APP_DIR}/"
c_info "上传前端静态资源 → ${APP_DIR}/webroot ..."
rsync -az --delete -e "$RSYNC_RSH" \
web/dist/ "${SERVER_USER}@${SERVER_HOST}:${APP_DIR}/webroot/"
c_ok "上传完成"
# ----------------------------- 5. 写入 .env(仅当不存在)-----------------------------
if remote "test -f ${APP_DIR}/.env"; then
c_info ".env 已存在,保留服务器现有配置(不覆盖密钥)"
else
[[ -z "$DB_PASS" ]] && DB_PASS="$(remote 'echo ${DB_PASS:-}')" # 防御
[[ -z "$AUTH_SECRET" ]] && AUTH_SECRET="$(openssl rand -hex 32)" && c_info "已自动生成 AUTH_SECRET"
c_info "写入 ${APP_DIR}/.env ..."
remote "cat > ${APP_DIR}/.env <<ENV
PORT=${BACKEND_PORT}
DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}
AUTH_SECRET=${AUTH_SECRET}
TARGET_NET_MARGIN=${TARGET_NET_MARGIN}
DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
LLM_BASE_URL=${LLM_BASE_URL}
LLM_MODEL=${LLM_MODEL}
LLM_TIMEOUT_MS=${LLM_TIMEOUT_MS}
ENV
chmod 600 ${APP_DIR}/.env"
c_ok ".env 已写入"
fi
# ----------------------------- 6. 安装生产依赖 -----------------------------
c_info "安装生产依赖(含 nodejieba 原生编译)..."
remote "cd ${APP_DIR} && npm install --omit=dev --no-audit --no-fund 2>&1 | tail -3"
c_ok "依赖安装完成"
# ----------------------------- 7. 首次部署:nginx + SSL -----------------------------
if [[ "$MODE" == "first-run" ]]; then
c_info "备份 nginx 配置 ..."
remote_sudo "tar czf /home/${SERVER_USER}/nginx-backup-\$(date +%Y%m%d%H%M%S).tgz -C /etc nginx 2>/dev/null || true"
remote_sudo "mkdir -p /var/www/html/.well-known/acme-challenge && chown -R ${SERVER_USER}:${SERVER_USER} /var/www/html/.well-known"
c_info "写入 nginx 站点(HTTP,先用于签发证书)..."
remote_sudo "tee /etc/nginx/sites-available/${DOMAIN} >/dev/null <<NGINX
server {
listen 80;
listen [::]:80;
server_name ${DOMAIN};
root ${APP_DIR}/webroot;
index index.html;
location /.well-known/acme-challenge/ { root /var/www/html; }
location /api/ {
proxy_pass http://127.0.0.1:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host \\\$host;
proxy_set_header X-Real-IP \\\$remote_addr;
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \\\$scheme;
client_max_body_size 20m;
}
location / { try_files \\\$uri \\\$uri/ /index.html; }
}
NGINX"
remote_sudo "ln -sf /etc/nginx/sites-available/${DOMAIN} /etc/nginx/sites-enabled/${DOMAIN} && nginx -t && systemctl reload nginx"
c_ok "nginx HTTP 站点已启用"
c_info "签发 Let's Encrypt 证书(acme.sh,需 ${DOMAIN} 已解析到本机)..."
if remote "test -x ~/.acme.sh/acme.sh"; then
remote "~/.acme.sh/acme.sh --issue -d ${DOMAIN} -w /var/www/html --server letsencrypt 2>&1 | tail -3 || true"
remote_sudo "mkdir -p /etc/nginx/ssl/${DOMAIN} && chown ${SERVER_USER}:${SERVER_USER} /etc/nginx/ssl/${DOMAIN}"
# 允许 acme 续期后免密 reload nginx
remote_sudo "echo '${SERVER_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx, /bin/systemctl reload nginx' > /etc/sudoers.d/${APP_NAME}-nginx-reload && chmod 440 /etc/sudoers.d/${APP_NAME}-nginx-reload"
remote "~/.acme.sh/acme.sh --install-cert -d ${DOMAIN} --ecc --key-file /etc/nginx/ssl/${DOMAIN}/key.pem --fullchain-file /etc/nginx/ssl/${DOMAIN}/fullchain.pem --reloadcmd 'sudo systemctl reload nginx' 2>&1 | tail -3 || true"
if remote "test -s /etc/nginx/ssl/${DOMAIN}/fullchain.pem"; then
c_info "切换 nginx 到 HTTPSHTTP 301 跳转)..."
remote_sudo "tee /etc/nginx/sites-available/${DOMAIN} >/dev/null <<NGINX
server {
listen 80; listen [::]:80;
server_name ${DOMAIN};
location /.well-known/acme-challenge/ { root /var/www/html; }
location / { return 301 https://\\\$host\\\$request_uri; }
}
server {
listen 443 ssl; listen [::]:443 ssl;
server_name ${DOMAIN};
ssl_certificate /etc/nginx/ssl/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/${DOMAIN}/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
root ${APP_DIR}/webroot;
index index.html;
location /api/ {
proxy_pass http://127.0.0.1:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host \\\$host;
proxy_set_header X-Real-IP \\\$remote_addr;
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \\\$scheme;
client_max_body_size 20m;
}
location / { try_files \\\$uri \\\$uri/ /index.html; }
}
NGINX"
remote_sudo "nginx -t && systemctl reload nginx"
c_ok "HTTPS 已启用"
else
c_warn "证书未签发成功,站点暂以 HTTP 提供。请确认 ${DOMAIN} 的 DNS 已指向 ${SERVER_HOST} 后重跑 --first-run"
fi
else
c_warn "未检测到 acme.sh,跳过 SSL。可手动安装后再配置 HTTPS"
fi
fi
# ----------------------------- 8. 启动 / 重启 PM2 -----------------------------
c_info "启动 / 重启后端进程 (PM2) ..."
if remote "pm2 describe ${APP_NAME} >/dev/null 2>&1"; then
remote "pm2 restart ${APP_NAME} --update-env"
else
remote "cd ${APP_DIR} && pm2 start dist/server/index.js --name ${APP_NAME} --cwd ${APP_DIR}"
fi
remote "pm2 save >/dev/null 2>&1 || true"
c_ok "后端已运行"
# ----------------------------- 9. 健康检查 -----------------------------
c_info "健康检查 ..."
sleep 2
HEALTH="$(remote "curl -s http://127.0.0.1:${BACKEND_PORT}/api/health" || true)"
LLM="$(remote "curl -s http://127.0.0.1:${BACKEND_PORT}/api/llm/status" || true)"
echo " /api/health → ${HEALTH}"
echo " /api/llm/status → ${LLM}"
if [[ "$HEALTH" == *'"status":"ok"'* ]]; then
c_ok "部署完成 ✅ 访问: https://${DOMAIN}"
else
c_err "健康检查未通过,请查看日志: ./deploy.sh --logs"
exit 1
fi