Files
asd-backend/lib/common.sh
milky0217 2ce97243ab
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
fix: 全量代码审计修复 — 30项
P0 - Panic 风险修复:
  - payment.rs: unwrap() → let-else safe handling
  - payment.rs: get_jwt_secret() expect → Result/AppError
  - auth.rs: openid 切片添加 len >= 8 守卫
  - main.rs: 启动时 expect → unwrap_or_else 描述性 panic
  - main.rs: Directive::from_str 添加 fallback

P1 - 逻辑/安全修复:
  - payment.rs: urlencoding() + 解码 bug 修复 (移除 had_escape)
  - payment.rs: Mock 支付添加 check_mock_payment_allowed 检查
  - db.rs: 永久会员 NULL → 2099-12-31 一致化
  - user.rs: 维护模式添加安全说明注释
  - 自动清理 unused_variables 警告 (_is_mobile)

P2 - 错误吞没修复:
  - main.rs: 3 处定时任务 let _ = → if let Err = tracing::error!
  - db.rs + admin.rs: 7 处通知/审计日志 let _ = → tracing::warn!
  - auth.rs: refresh token 保存 add warn 日志

P3 - 死代码清理:
  - models.rs: 移除 TokenResponse (dead)
  - models.rs: 移除 AppState 中 5 个未使用字段 (env var 直接读取)
  - error.rs: 移除 3 个 dead ErrorResponse 方法
  - rate_limiter.rs: extract_client_ip_from_header → #[cfg(test)]
  - models.rs: 注释 typo fix (user_ytpe → user_type)
  - db.rs: RefreshToken 添加 deserialization 注释

Shell 脚本修复:
  - deploy.sh: run_migrations 移到 restart_service 之前
  - test.sh: 移除 EXIT trap 覆盖; heredoc 引号修复; 维护模式添加 restart
  - common.sh: mock_key 添加 sed 转义 (防 / & 注入)

验证: cargo check 0 warnings, 8 tests passed
2026-07-23 12:40:47 +08:00

485 lines
20 KiB
Bash
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.
# ===========================================
# common.sh — deploy.sh 共享基础设施
# ===========================================
# ---------- 彩色输出 ----------
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
log_dry() { echo -e "${YELLOW}[DRY-RUN]${NC} $1"; }
# 默认超时(秒)
: "${DEPLOY_TIMEOUT_SEC:=400}"
# ---------- Git 信息 ----------
get_git_info() {
local b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
local c=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "${b}@${c}"
}
# ---------- SSH 连接复用 ----------
get_ssh_socket() { echo "/tmp/ssh-master-${REMOTE_HOST}-$$"; }
ssh_connect() {
[ "$DRY_RUN" = true ] && { log_dry "ssh -o ControlMaster=auto -o ControlPath=$(get_ssh_socket) ${REMOTE_USER}@${REMOTE_HOST}"; return 0; }
local socket; socket=$(get_ssh_socket)
# 测试连接
if ! timeout "${DEPLOY_TIMEOUT_SEC}" ssh -o "ControlPath=${socket}" -o "ConnectionAttempts=3" -o "ConnectTimeout=10" \
-o "ControlMaster=no" "${REMOTE_USER}@${REMOTE_HOST}" "echo ok" &>/dev/null; then
log_info "建立 SSH 连接..."
timeout "${DEPLOY_TIMEOUT_SEC}" ssh -o ControlMaster=auto -o ControlPath="${socket}" -o ControlPersist=600 \
-o ConnectionAttempts=3 -o ConnectTimeout=10 \
-N "${REMOTE_USER}@${REMOTE_HOST}" &
sleep 2
fi
}
ssh_disconnect() {
[ "$DRY_RUN" = true ] && return 0
local socket; socket=$(get_ssh_socket)
[ -S "${socket}" ] 2>/dev/null || return 0
ssh -o "ControlPath=${socket}" -O exit "${REMOTE_USER}@${REMOTE_HOST}" 2>/dev/null || true
}
# SSH 快捷执行(复用 ControlMaster
remote() { timeout "${DEPLOY_TIMEOUT_SEC}" ssh -o "ConnectTimeout=10" -o "ServerAliveInterval=30" -o "ControlPath=$(get_ssh_socket)" "${REMOTE_USER}@${REMOTE_HOST}" "$@"; }
# ---------- 部署日志 ----------
get_deploy_log() { echo "${REMOTE_DIR}/deploy.log"; }
log_to_file() {
[ "$DRY_RUN" = true ] && return 0
remote "echo '[$(date '+%Y-%m-%d %H:%M:%S')] ${APP_ENV} ${GIT_VERSION} $1' >> $(get_deploy_log)" 2>/dev/null || true
}
# ---------- Webhook ----------
send_webhook() {
[ -z "${WEBHOOK_URL:-}" ] && return 0
local payload; payload=$(cat <<EOF
{"app":"rust-backend","env":"${APP_ENV}","status":"$1","version":"${GIT_VERSION}","message":"$2","timestamp":"$(date -Iseconds)"}
EOF
)
[ "$DRY_RUN" = true ] && { log_dry "curl -X POST '${WEBHOOK_URL}' -d '${payload}'"; return 0; }
curl -s -X POST "${WEBHOOK_URL}" -H "Content-Type: application/json" -d "${payload}" >/dev/null 2>&1 || true
}
# ---------- 环境配置systemd service----------
get_service_file_local() {
local target="${BG_TARGET:-}"
if [ -n "$target" ]; then
echo "${SCRIPT_DIR}/deploy/rust-backend-${target}.service"
else
echo "${SCRIPT_DIR}/deploy/rust-backend-${APP_ENV}.service"
fi
}
check_service_file() {
log_step "检查远程 systemd service 文件..."
[ "$DRY_RUN" = true ] && { log_dry "检查 ${SERVICE_NAME}"; return 0; }
if ! remote "systemctl cat '${SERVICE_NAME}'" &>/dev/null; then
log_error "systemd service ${SERVICE_NAME} 不存在,请先用 --deploy-service 安装"
exit 1
fi
local content; content=$(remote "systemctl cat '${SERVICE_NAME}'" 2>/dev/null || echo "")
[ -z "$content" ] && { log_error "service 内容为空"; exit 1; }
local missing=0 ph=0
local required_vars=("DATABASE_URL" "JWT_SECRET" "WECHAT_APPID" "WECHAT_SECRET")
[ "${APP_ENV}" = "production" ] && required_vars+=("SSL_KEY_PATH" "SSL_CERT_PATH")
for var in "${required_vars[@]}"; do
local val; val=$(echo "$content" | grep -E "^Environment=${var}=" | head -1 | sed "s/^Environment=${var}=//")
if [ -z "$val" ]; then
log_error " 缺少: Environment=${var}="; missing=$((missing+1))
elif echo "$val" | grep -qiE "^(your_|placeholder|change_me|xxx|test_)"; then
log_warn " 占位符: ${var}=${val}"; ph=$((ph+1))
fi
done
[ $missing -gt 0 ] && { log_error "service 配置检查失败:${missing} 个缺失"; exit 1; }
[ $ph -gt 0 ] && log_warn "service 存在 ${ph} 个占位符,部署后可能无法正常运行"
log_info "service 配置检查通过"
}
# ---------- 蓝绿部署:检查目标是否为当前活动环境 ----------
get_proxy_conf() {
if [ "${APP_ENV}" = "development" ]; then
echo "/etc/nginx/sites-enabled/dev.xmclassmate.top"
else
echo "/etc/nginx/sites-enabled/xmclassmate.top"
fi
}
check_not_active() {
[ -z "$BG_TARGET" ] && return 0 # 非蓝绿部署,跳过
local proxy_conf; proxy_conf=$(get_proxy_conf)
local active_env
active_env=$(remote "sudo cat ${proxy_conf} 2>/dev/null" 2>/dev/null | grep -oP '127\.0\.0\.1:\d+' | head -1 || echo "")
local current_target
case "$active_env" in
*4433) current_target="blue" ;;
*4434) current_target="green" ;;
*) current_target="unknown" ;;
esac
if [ "$current_target" = "$BG_TARGET" ]; then
log_error "禁止部署: ${BG_TARGET} (:${BG_PORT}) 是当前活动环境"
log_error "请部署到待命环境: $( [ "$BG_TARGET" = "blue" ] && echo 'green' || echo 'blue' )"
exit 1
fi
log_info "目标 ${BG_TARGET} 非活动环境(当前: ${current_target}),可以部署 ✅"
}
deploy_service_file() {
log_step "部署 systemd service 文件..."
[ "$DRY_RUN" = true ] && { log_dry "上传 $(get_service_file_local) → /etc/systemd/system/${SERVICE_NAME}"; return 0; }
if [ ! -f "$(get_service_file_local)" ]; then
log_error "本地 service 文件不存在: $(get_service_file_local)"
log_info "可通过 --init-env 生成模板"
exit 1
fi
rsync -avzP "$(get_service_file_local)" "${REMOTE_USER}@${REMOTE_HOST}:/etc/systemd/system/${SERVICE_NAME}"
# 开发环境自动配置 mock login带随机密钥
if [ "${APP_ENV}" = "development" ]; then
local has_mock; has_mock=$(remote "grep -c 'MOCK_LOGIN_ENABLED' /etc/systemd/system/${SERVICE_NAME} 2>/dev/null || echo 0")
if [ "$has_mock" = "0" ]; then
local mock_key; mock_key=$(openssl rand -base64 12 2>/dev/null | tr -d '\n' || echo "dev-mock-$(date +%s)")
local escaped_key; escaped_key=$(echo "$mock_key" | sed 's/[\/&]/\\&/g')
remote "sed -i '/^\[Service\]/a Environment=MOCK_LOGIN_ENABLED=true\nEnvironment=MOCK_LOGIN_KEY=${escaped_key}' /etc/systemd/system/${SERVICE_NAME}"
log_info "自动配置 MOCK_LOGIN_ENABLED=true, MOCK_LOGIN_KEY=${mock_key}"
fi
fi
remote "systemctl daemon-reload"
log_info "service 文件部署完成,使用 systemctl restart ${SERVICE_NAME} 重启"
}
init_env() {
log_step "初始化 systemd service 文件(从模板生成)..."
[ "$DRY_RUN" = true ] && { log_dry "生成 $(get_service_file_local)"; return 0; }
if [ -f "$(get_service_file_local)" ]; then
log_warn "$(get_service_file_local) 已存在,继续将覆盖!"
read -p "是否覆盖? [y/N] " -n 1 -r; echo
[[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; return 0; }
cp "$(get_service_file_local)" "$(get_service_file_local).bak.$(date +%Y%m%d_%H%M%S)"
fi
local jwt_secret
jwt_secret=$(openssl rand -base64 32 2>/dev/null | tr -d '\n' || echo "change-me-$(date +%s)-$$")
mkdir -p "${SCRIPT_DIR}/deploy"
cat > "$(get_service_file_local)" << SERVEOF
[Unit]
Description=Rust Backend ${APP_ENV^} Service
After=network.target postgresql.service
Wants=network.target
[Service]
Type=simple
User=rust-backend
Group=rust-backend
WorkingDirectory=${REMOTE_DIR}
ExecStart=${REMOTE_DIR}/rust-backend
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
Environment=DATABASE_URL=请替换为实际数据库连接
Environment=WECHAT_APPID=请替换为实际微信 AppID
Environment=WECHAT_SECRET=请替换为实际微信 Secret
Environment=JWT_SECRET=${jwt_secret}
Environment=SSL_KEY_PATH=
Environment=SSL_CERT_PATH=
Environment=RUST_LOG=info
Environment=APP_VERSION=0.3.0
Environment=FREE_USER_DATA_LIMIT=20
# 支付宝密钥存储在 .env 文件(不在 Git 中)
EnvironmentFile=${REMOTE_DIR}/.env
SERVEOF
chmod 600 "$(get_service_file_local)"
log_info "service 模板已生成: $(get_service_file_local)"
log_warn "请手动填写 DATABASE_URL / WECHAT_APPID / WECHAT_SECRET"
log_info "然后使用 ./deploy.sh ${APP_ENV} --deploy-service 上传"
}
# ---------- 编译 ----------
build_project() {
log_step "编译项目 (release 模式)..."
[ "$DRY_RUN" = true ] && { log_dry "cargo build --release"; return 0; }
APP_ENV="${APP_ENV}" cargo build --release || { log_error "编译失败!"; exit 1; }
log_info "编译成功"
}
# ---------- 备份 ----------
backup_old_version() {
log_step "备份旧版本..."
local ts; ts=$(date +%Y%m%d_%H%M%S)
local gh; gh=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
local md5=""
[ "$DRY_RUN" = false ] && md5=$(remote "md5sum '${REMOTE_DIR}/${PROJECT_NAME}' 2>/dev/null | cut -d' ' -f1" || echo "")
BACKUP_PATH="${REMOTE_DIR}/${PROJECT_NAME}.backup.${ts}.${gh}.${md5:0:8}"
[ "$DRY_RUN" = true ] && { log_dry "备份到 ${BACKUP_PATH}"; return 0; }
remote "if [ -f '${REMOTE_DIR}/${PROJECT_NAME}' ]; then cp '${REMOTE_DIR}/${PROJECT_NAME}' '${BACKUP_PATH}'; echo '${BACKUP_PATH}' > '${REMOTE_DIR}/.last_deployed'; fi"
log_info "备份完成"
}
cleanup_old_backups() {
local keep="${1:-5}"
[ "$DRY_RUN" = true ] && { log_dry "保留最近 ${keep} 个备份"; return 0; }
local backups; backups=$(remote "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null" || echo "")
[ -z "$backups" ] && return 0
local seen="" to_del=""
while IFS= read -r b; do
[ -z "$b" ] && continue
local md5; md5=$(basename "$b" | rev | cut -d'.' -f2 | rev || echo "")
[ -z "$md5" ] && { to_del="${to_del}${b}"$'\n'; continue; }
echo "$seen" | grep -q "^${md5}$" && to_del="${to_del}${b}"$'\n' || seen="${seen}${md5}"$'\n'
done <<< "$backups"
# 如果唯一版本数超过 keep按时间删除最旧的溢出部分
local count=0
while IFS= read -r b; do
[ -z "$b" ] && continue; count=$((count+1))
[ "$count" -gt "$keep" ] && to_del="${to_del}${b}"$'\n'
done <<< "$backups"
[ -z "$to_del" ] && return 0
log_step "清理旧备份(保留 ${keep} 个版本)..."
echo "$to_del" | while IFS= read -r f; do
[ -n "$f" ] && remote "rm -f '${f}'" 2>/dev/null && echo " 删除: $(basename "$f")" || true
done
}
backup_database() {
log_step "备份数据库..."
[ "$DRY_RUN" = true ] && { log_dry "pg_dump → ${REMOTE_DIR}/backups/"; return 0; }
local ts; ts=$(date +%Y%m%d_%H%M%S)
local bf="${REMOTE_DIR}/backups/${DB_NAME}_${ts}.dump"
remote "mkdir -p ${REMOTE_DIR}/backups"
if remote "sudo docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > /tmp/backup_${ts}.dump && mv /tmp/backup_${ts}.dump '${bf}'" 2>&1; then
log_info "数据库备份成功: ${DB_NAME}_${ts}.dump"
cleanup_old_db_backups 7
else
log_warn "数据库备份失败,继续部署"
fi
}
cleanup_old_db_backups() {
local keep="${1:-7}"
local to_del; to_del=$(remote "ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump* 2>/dev/null | tail -n +$((keep+1))" || echo "")
[ -z "$to_del" ] && return 0
log_step "清理旧数据库备份(保留 ${keep} 个)..."
echo "$to_del" | while read -r f; do [ -n "$f" ] && remote "rm -f '${f}'" 2>/dev/null && echo " 删除: $(basename "$f")" || true; done
}
list_backups() {
log_step "可用备份列表..."
[ "$DRY_RUN" = true ] && { log_dry "ls ${REMOTE_DIR}/${PROJECT_NAME}.backup.*"; return 0; }
remote "ls -la ${REMOTE_DIR}/${PROJECT_NAME}.backup.*" 2>/dev/null || echo "无备份文件"
}
# ---------- 上传 ----------
upload_binary() {
log_step "上传二进制文件..."
local src="./target/release/${PROJECT_NAME}"
[ ! -f "$src" ] && { log_error "编译产物不存在: $src"; exit 1; }
[ "$DRY_RUN" = true ] && { log_dry "rsync ${src}${REMOTE_DIR}/"; return 0; }
rsync -avzP --progress "$src" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
log_info "上传完成"
}
upload_dir() {
local label="$1" src="$2" dst="${3:-$2}"
log_step "上传 ${label}..."
[ "$DRY_RUN" = true ] && { log_dry "rsync ${src}/ → ${REMOTE_DIR}/${dst}/"; return 0; }
[ ! -d "$src" ] && { log_warn "${src} 目录不存在,跳过"; return 0; }
rsync -avzP --progress "${src}/" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/${dst}/"
log_info "${label} 上传完成"
}
upload_script() {
local label="$1" src="$2" dst="$3"
log_step "上传 ${label}..."
[ "$DRY_RUN" = true ] && { log_dry "rsync ${src}${REMOTE_DIR}/${dst}"; return 0; }
[ ! -f "$src" ] && { log_warn "${src} 不存在,跳过"; return 0; }
rsync -avzP --progress "$src" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/${dst}"
remote "chmod +x ${REMOTE_DIR}/${dst}" 2>/dev/null || true
log_info "${label} 上传完成"
}
# ---------- 迁移 ----------
run_migrations() {
log_step "执行数据库迁移..."
[ "$DRY_RUN" = true ] && { local n; n=$(ls ./migrations/*.sql 2>/dev/null | wc -l || echo 0); log_dry "发现 ${n} 个迁移文件"; return 0; }
# 先备份再执行
local sql_files; sql_files=$(remote "ls ${REMOTE_DIR}/migrations/*.sql 2>/dev/null | sort" || echo "")
[ -n "$sql_files" ] && backup_database
# 创建迁移追踪表
remote "sudo docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" <<<'CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ DEFAULT NOW());' 2>/dev/null || true
for f in $sql_files; do
local name; name=$(basename "$f")
log_info "检查迁移: ${name}"
local done; done=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT COUNT(*) FROM _migrations WHERE name='${name}'\"" 2>/dev/null || echo "0")
[ "$done" != "0" ] && { log_info " 跳过(已执行): ${name}"; continue; }
log_info " 执行: ${name}"
remote "cat '${f}' | sudo docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" && \
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"INSERT INTO _migrations (name) VALUES ('${name}')\"" && \
log_info " 完成: ${name}" || log_warn " 迁移 ${name} 可能已执行或失败"
done
log_info "数据库迁移完成"
}
# ---------- 服务 ----------
restart_service() {
log_step "重启服务 '${SERVICE_NAME}'..."
[ "$DRY_RUN" = true ] && { log_dry "systemctl restart ${SERVICE_NAME}"; return 0; }
# 部署前先确保 service 文件存在
if ! remote "systemctl cat '${SERVICE_NAME}'" &>/dev/null; then
log_warn "${SERVICE_NAME} 未安装,尝试部署 service 文件..."
deploy_service_file
fi
remote "systemctl restart ${SERVICE_NAME}" && log_info "服务重启成功" || log_error "服务重启失败!"
sleep 3
}
# ---------- 回滚 ----------
rollback() {
log_step "查找可用备份..."
[ "$DRY_RUN" = true ] && { log_dry "回滚预览"; return 0; }
local backups
backups=$(remote "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null" || echo "")
[ -z "$backups" ] && { log_error "没有可用备份"; return 1; }
echo ""; echo -e "${BLUE}可用备份:${NC}"; echo "$backups" | head -10 | nl -v 0
local count; count=$(echo "$backups" | wc -l)
read -p "选择备份 [0-$(($count>10?9:$count-1)),默认 0]: " -n 1 -r; echo
[[ ! $REPLY =~ ^[0-9]$ ]] && REPLY=0
local chosen; chosen=$(echo "$backups" | sed -n "$((REPLY+1))p")
echo -e "${YELLOW}回滚到: ${chosen}${NC}"
[ "$SKIP_CONFIRM" = false ] && { read -p "确认? [y/N] " -n 1 -r; echo; [[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; return 0; } }
remote "systemctl stop ${SERVICE_NAME}" 2>/dev/null || true
remote "cp '${chosen}' '${REMOTE_DIR}/${PROJECT_NAME}'"
remote "systemctl start ${SERVICE_NAME}"
log_info "回滚完成"
log_to_file "MANUAL_ROLLBACK to $(basename ${chosen})"
send_webhook "rollback" "手动回滚: $(basename ${chosen})"
}
rollback_on_failure() {
local bp="$1"; [ -z "$bp" ] && { log_error "未指定备份路径"; return 1; }
log_error "========== 测试失败,自动回滚 =========="
[ "$DRY_RUN" = true ] && { log_dry "回滚到 ${bp}"; return 0; }
remote "systemctl stop ${SERVICE_NAME}" 2>/dev/null || true
remote "cp '${bp}' '${REMOTE_DIR}/${PROJECT_NAME}'"
remote "systemctl start ${SERVICE_NAME}"; sleep 3
log_info "自动回滚完成"
log_to_file "AUTO_ROLLBACK to $(basename ${bp})"
send_webhook "failure" "部署失败,自动回滚: $(basename ${bp})"
}
# ---------- 日志查看 ----------
view_logs() {
local lines="${LOG_LINES:-50}"
log_step "查看后端日志(最后 ${lines} 行)..."
[ "$DRY_RUN" = true ] && { log_dry "journalctl -u ${SERVICE_NAME} -n ${lines}"; return 0; }
remote "journalctl -u ${SERVICE_NAME} -n ${lines} --no-pager 2>/dev/null" || log_warn "无法读取 journalctl 日志"
}
tail_logs() {
local lines="${LOG_LINES:-100}"
log_step "实时跟踪日志..."
[ "$DRY_RUN" = true ] && return 0
ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "journalctl -u ${SERVICE_NAME} -n ${lines} -f"
}
status() {
log_step "服务状态..."
[ "$DRY_RUN" = true ] && { log_dry "systemctl status ${SERVICE_NAME}"; return 0; }
echo -e "${BLUE}=== ${SERVICE_NAME} ===${NC}"
remote "systemctl status ${SERVICE_NAME} --no-pager"
echo -e "${BLUE}=== 最近日志 ===${NC}"
remote "journalctl -u ${SERVICE_NAME} -n 20 --no-pager 2>/dev/null" || log_warn "无法读取 journalctl 日志"
}
# ---------- 确认 ----------
confirm_deploy() {
[ "$DRY_RUN" = true ] || [ "$SKIP_CONFIRM" = true ] && return 0
echo ""; echo -e "${YELLOW}========== 部署确认 ==========${NC}"
echo -e " 环境: ${APP_ENV}"
echo -e " 远程目录: ${REMOTE_DIR}"
echo -e " 服务: ${SERVICE_NAME}"
echo -e " Git: ${GIT_VERSION}"
echo -e "================================${NC}"
read -p "确认部署? [y/N] " -n 1 -r; echo
[[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; exit 0; }
}
show_deploy_info() {
echo ""; echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Rust Backend 部署脚本${NC}"
echo -e "${BLUE}========================================${NC}"
echo -e " 项目: ${PROJECT_NAME}"
echo -e " 环境: ${APP_ENV}"
echo -e " 远程主机: ${REMOTE_USER}@${REMOTE_HOST}"
echo -e " 远程目录: ${REMOTE_DIR}"
echo -e " 服务: ${SERVICE_NAME}"
echo -e " Git 版本: ${GIT_VERSION}"
[ "$DRY_RUN" = true ] && echo -e " 模式: ${YELLOW}预览 (DRY-RUN)${NC}"
echo -e "${BLUE}========================================${NC}"
}
# ---------- SSL 证书检查 ----------
check_ssl_expiry() {
[ "${APP_ENV}" != "production" ] && return 0 # 非生产环境跳过
log_step "检查 SSL 证书到期时间..."
[ "$DRY_RUN" = true ] && { log_dry "openssl x509 -checkend 2592000"; return 0; }
# 读取 SSL 证书
local cert_info; cert_info=$(remote "sudo openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -enddate -checkend 2592000 2>/dev/null" || echo "")
if echo "$cert_info" | grep -q "Certificate will expire"; then
local expiry; expiry=$(remote "sudo openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -enddate 2>/dev/null | cut -d= -f2" || echo "未知")
local days_left; days_left=$(remote "sudo openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -checkend 86400 2>/dev/null | grep -c 'will expire'" || echo "0")
log_warn "SSL 证书将于 ${expiry} 到期(不足 30 天)!"
[ "$days_left" -gt "0" ] && log_warn " 警告:证书将在 1 天内到期,请立即续期!"
else
log_info "SSL 证书有效期超过 30 天 ✅"
fi
}
# ---------- 依赖检查 ----------
check_dependencies() {
log_step "检查依赖..."
for cmd in cargo rsync ssh; do
command -v "$cmd" &>/dev/null || { log_error "依赖 '$cmd' 未安装"; exit 1; }
done
log_info "依赖检查通过"
}
# ---------- 清理 ----------
cleanup() { ssh_disconnect 2>/dev/null || true; }
trap cleanup EXIT