Files
RiskAgent/deploy.sh
T

270 lines
12 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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