270 lines
12 KiB
Bash
Executable File
270 lines
12 KiB
Bash
Executable File
#!/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 "需要 sshpass(SERVER_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 到 HTTPS(HTTP 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
|