From 2470f4265b9f358f0ae2699e0d6ca7571079be71 Mon Sep 17 00:00:00 2001 From: milky0217 Date: Tue, 2 Jun 2026 10:34:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E6=A3=80=E6=9F=A5=E8=84=9A=E6=9C=AC=20check-?= =?UTF-8?q?members.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/rust-backend-blue.service | 39 ++++++ deploy/rust-backend-green.service | 39 ++++++ scripts/check-members.sh | 201 ++++++++++++++++++++++++++++++ scripts/switch-env.sh | 138 ++++++++++++++++++++ src/main.rs | 10 +- 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 deploy/rust-backend-blue.service create mode 100644 deploy/rust-backend-green.service create mode 100755 scripts/check-members.sh create mode 100755 scripts/switch-env.sh diff --git a/deploy/rust-backend-blue.service b/deploy/rust-backend-blue.service new file mode 100644 index 0000000..692b677 --- /dev/null +++ b/deploy/rust-backend-blue.service @@ -0,0 +1,39 @@ +[Unit] +Description=Rust Backend Blue (Active) +After=network.target postgresql.service +Wants=network.target + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/root/rust/rust_backend +ExecStart=/root/rust/rust_backend/rust-backend +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +Environment=DATABASE_URL=postgres://milkydata:44n6FdB8CdDAk5rk@127.0.0.1:5432/milkydata +Environment=WECHAT_APPID=wx5b00eb90621802f7 +Environment=WECHAT_SECRET=494efc513faa310bfba588bda2849bfd +Environment=JWT_SECRET=tribesman-pavestone-hatchling-wrangle-silly-embargo +Environment=SSL_KEY_PATH=/etc/ssl/rust_backend/privkey.pem +Environment=SSL_CERT_PATH=/etc/ssl/rust_backend/fullchain.pem +Environment=RUST_LOG=info +Environment=APP_VERSION=0.2.5 +Environment=FREE_USER_DATA_LIMIT=20 +Environment=APP_BASE_URL=https://xmclassmate.top +Environment=SERVER_PORT=4433 +Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1 + +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/root/rust/rust_backend + +SyslogIdentifier=rust-backend-blue + +[Install] +WantedBy=multi-user.target diff --git a/deploy/rust-backend-green.service b/deploy/rust-backend-green.service new file mode 100644 index 0000000..455ecb9 --- /dev/null +++ b/deploy/rust-backend-green.service @@ -0,0 +1,39 @@ +[Unit] +Description=Rust Backend Green (Standby) +After=network.target postgresql.service +Wants=network.target + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/root/rust/rust_backend +ExecStart=/root/rust/rust_backend/rust-backend +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +Environment=DATABASE_URL=postgres://milkydata:44n6FdB8CdDAk5rk@127.0.0.1:5432/milkydata +Environment=WECHAT_APPID=wx5b00eb90621802f7 +Environment=WECHAT_SECRET=494efc513faa310bfba588bda2849bfd +Environment=JWT_SECRET=tribesman-pavestone-hatchling-wrangle-silly-embargo +Environment=SSL_KEY_PATH=/etc/ssl/rust_backend/privkey.pem +Environment=SSL_CERT_PATH=/etc/ssl/rust_backend/fullchain.pem +Environment=RUST_LOG=info +Environment=APP_VERSION=0.2.5 +Environment=FREE_USER_DATA_LIMIT=20 +Environment=APP_BASE_URL=https://xmclassmate.top +Environment=SERVER_PORT=4434 +Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1 + +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/root/rust/rust_backend + +SyslogIdentifier=rust-backend-green + +[Install] +WantedBy=multi-user.target diff --git a/scripts/check-members.sh b/scripts/check-members.sh new file mode 100755 index 0000000..91d3f39 --- /dev/null +++ b/scripts/check-members.sh @@ -0,0 +1,201 @@ +#!/bin/bash +# =========================================== +# 检查会员订阅情况 +# =========================================== +# 用法: +# ./check-members.sh # 生产数据库 +# ./check-members.sh --dev # 开发数据库 (milkydata_dev) +# ./check-members.sh --dev --json # JSON 格式输出 +# =========================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$PROJECT_ROOT/.env" + +# ---- 颜色 ---- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# ---- 参数 ---- +DB_NAME="milkydata" +OUTPUT_JSON=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --dev) DB_NAME="milkydata_dev"; shift ;; + --json) OUTPUT_JSON=true; shift ;; + --help|-h) + echo "用法: $0 [--dev] [--json]" + echo " --dev 查询开发数据库 (milkydata_dev)" + echo " --json JSON 格式输出" + exit 0 + ;; + *) echo "未知参数: $1"; exit 1 ;; + esac +done + +# ---- 获取 SSH 配置 ---- +SSH_HOST=$(grep '^SSH_SERVER=' "$ENV_FILE" 2>/dev/null | head -1 | cut -d'=' -f2- | tr -d '"' | tr -d "'" | xargs) +if [ -z "$SSH_HOST" ]; then + echo "${RED}❌ 未找到 SSH_SERVER 配置,请检查 $ENV_FILE${NC}" + exit 1 +fi + +echo -e "${CYAN}══════════════════════════════════════${NC}" +echo -e "${CYAN} 会员订阅状态检查${NC}" +echo -e "${CYAN} 数据库: ${BOLD}$DB_NAME${NC}" +echo -e "${CYAN} 时间: $(date '+%Y-%m-%d %H:%M:%S')${NC}" +echo -e "${CYAN}══════════════════════════════════════${NC}" +echo "" + +# ---- 总览 ---- +SQL_OVERVIEW=$(cat <<'SQL' +SELECT + COUNT(*) AS total_users, + COUNT(*) FILTER (WHERE is_member = true) AS total_members, + COUNT(*) FILTER (WHERE is_member = true AND membership_expires_at > NOW()) AS active_members, + COUNT(*) FILTER (WHERE is_member = true AND membership_expires_at <= NOW()) AS expired_members, + COUNT(*) FILTER (WHERE is_member = true AND membership_expires_at IS NULL) AS permanent_members, + COUNT(*) FILTER (WHERE is_member = true + AND membership_expires_at IS NOT NULL + AND membership_expires_at > NOW() + AND membership_expires_at <= NOW() + INTERVAL '7 days') AS expiring_7d, + COUNT(*) FILTER (WHERE is_member = true + AND membership_expires_at IS NOT NULL + AND membership_expires_at > NOW() + AND membership_expires_at <= NOW() + INTERVAL '30 days') AS expiring_30d +FROM users; +SQL +) + +OVERVIEW=$(ssh "$SSH_HOST" "psql -d $DB_NAME -At -c \"$SQL_OVERVIEW\"" 2>/dev/null) || { + echo -e "${RED}❌ SSH 连接失败: $SSH_HOST${NC}" + exit 1 +} + +IFS='|' read -r total_users total_members active_members expired_members permanent_members expiring_7d expiring_30d <<< "$OVERVIEW" + +# ---- 活跃会员列表 ---- +SQL_LIST=$(cat <<'SQL' +SELECT + id, + COALESCE(openid, 'N/A') AS openid, + COALESCE(nickname, CONCAT('用户 #', id)) AS display_name, + membership_expires_at::date AS expires_on, + CASE + WHEN membership_expires_at IS NULL THEN '永久' + WHEN membership_expires_at <= NOW() THEN '已过期' + ELSE CONCAT('剩 ', EXTRACT(DAY FROM membership_expires_at - NOW()), ' 天') + END AS status, + CASE + WHEN membership_expires_at IS NULL THEN 99999 + WHEN membership_expires_at <= NOW() THEN -1 + ELSE EXTRACT(DAY FROM membership_expires_at - NOW())::int + END AS days_left +FROM users +WHERE is_member = true +ORDER BY + CASE WHEN membership_expires_at IS NULL THEN 0 ELSE 1 END, + membership_expires_at DESC; +SQL +) + +MEMBER_LIST=$(ssh "$SSH_HOST" "psql -d $DB_NAME -AqtF'|' -c \"$SQL_LIST\"" 2>/dev/null) + +# ---- 近期到期的活跃会员 ---- +SQL_EXPIRING=$(cat <<'SQL' +SELECT + id, + COALESCE(nickname, CONCAT('用户 #', id)) AS display_name, + membership_expires_at::date AS expires_on, + EXTRACT(DAY FROM membership_expires_at - NOW())::int AS days_left +FROM users +WHERE is_member = true + AND membership_expires_at IS NOT NULL + AND membership_expires_at > NOW() + AND membership_expires_at <= NOW() + INTERVAL '7 days' +ORDER BY membership_expires_at; +SQL +) + +EXPIRING_LIST=$(ssh "$SSH_HOST" "psql -d $DB_NAME -AqtF'|' -c \"$SQL_EXPIRING\"" 2>/dev/null) + +# ---- JSON 输出 ---- +if [ "$OUTPUT_JSON" = true ]; then + cat < 0 ? total_members * 100 / total_users : 0 ))%)" +echo -e " ${GREEN}活跃会员: ${BOLD}$active_members${NC}(已付费且未过期)" +echo -e " ${YELLOW}已过期会员: ${BOLD}$expired_members${NC}" +echo -e " ${BOLD}永久会员: ${BOLD}$permanent_members${NC}" +echo "" +echo -e " ${YELLOW}7 天内到期: ${BOLD}$expiring_7d${NC}" +echo -e " ${YELLOW}30 天内到期: ${BOLD}$expiring_30d${NC}" +echo "" + +# ---- 活跃会员列表 ---- +if [ -n "$MEMBER_LIST" ]; then + echo -e "${BOLD}📋 会员列表${NC}" + printf " ${CYAN}%-4s %-22s %-12s %s${NC}\n" "ID" "名称" "到期日" "状态" + echo " ─────────────────────────────────────────────" + while IFS='|' read -r id openid name expires_on status days_left; do + [ -z "$id" ] && continue + if [ "$status" = "永久" ]; then + printf " %-4d %-22s %-12s %s\n" "$id" "${name:0:22}" "永久" "🔒 永久" + elif [ "$status" = "已过期" ]; then + printf " %-4d %-22s %-12s %s\n" "$id" "${name:0:22}" "$expires_on" "❌ $status" + elif [ "$days_left" -le 7 ]; then + printf " %-4d %-22s %-12s %s\n" "$id" "${name:0:22}" "$expires_on" "⚠️ $status" + else + printf " %-4d %-22s %-12s %s\n" "$id" "${name:0:22}" "$expires_on" "✅ $status" + fi + done <<< "$MEMBER_LIST" + echo "" +fi + +# ---- 近期到期提醒 ---- +if [ -n "$EXPIRING_LIST" ]; then + echo -e "${BOLD}⏰ 近期到期(7 天内)${NC}" + while IFS='|' read -r id name expires_on days_left; do + [ -z "$id" ] && continue + echo -e " ${YELLOW}⚠️ 用户 #$id(${name})将于 $expires_on 到期,剩余 ${days_left} 天${NC}" + done <<< "$EXPIRING_LIST" + echo "" +fi + +echo -e "${CYAN}══════════════════════════════════════${NC}" diff --git a/scripts/switch-env.sh b/scripts/switch-env.sh new file mode 100755 index 0000000..20e1ba7 --- /dev/null +++ b/scripts/switch-env.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# =========================================== +# 蓝绿切换脚本 +# 切换生产环境 OpenResty proxy_pass 指向 +# 实现零停机切换 +# =========================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$PROJECT_ROOT/.env" + +# ---------- 配置 ---------- +SSH_HOST="root@1panel-server" +PROXY_DIR="/www/sites/xmclassmate.top/proxy" +ROOT_CONF="${PROXY_DIR}/root.conf" +NGINX_CONTAINER="1Panel-openresty-ABu5" + +# ---------- 颜色输出 ---------- +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } + +# ---------- 获取 SSH 主机 ---------- +get_ssh_host() { + grep '^SSH_SERVER=' "$ENV_FILE" 2>/dev/null | head -1 | cut -d'=' -f2- | tr -d '"' | tr -d "'" | xargs +} + +# ---------- 使用说明 ---------- +usage() { + echo "用法: $0 [--status|--switch blue|green|--help]" + echo "" + echo " --status 查看当前活动环境" + echo " --switch 切换到指定环境" + echo " --help 显示帮助" + exit 0 +} + +# ---------- 获取当前活动环境 ---------- +get_active() { + local proxy_conf; proxy_conf=$(ssh "$SSH_HOST" "docker exec ${NGINX_CONTAINER} cat ${ROOT_CONF}" 2>/dev/null || echo "") + if echo "$proxy_conf" | grep -q "127.0.0.1:4433"; then + echo "blue" + elif echo "$proxy_conf" | grep -q "127.0.0.1:4434"; then + echo "green" + else + echo "unknown" + fi +} + +# ---------- 查看状态 ---------- +show_status() { + local active; active=$(get_active) + echo "" + echo "═══════════════════════════════════════" + echo " 蓝绿部署状态" + echo "═══════════════════════════════════════" + echo "" + echo " 蓝色 :4433 $( [ "$active" = "blue" ] && echo '★ 活动中' || echo '○ 待命中' )" + echo " 绿色 :4434 $( [ "$active" = "green" ] && echo '★ 活动中' || echo '○ 待命中' )" + echo "" + echo " 反向代理: xmclassmate.top" + echo " 数据库: milkydata_dev" + echo "" + + # 检查两个端口是否可达 + local blue_ok; blue_ok=$(ssh "$SSH_HOST" "curl -s -o /dev/null -w '%{http_code}' --max-time 3 https://127.0.0.1:4433/health -k" 2>/dev/null || echo "000") + local green_ok; green_ok=$(ssh "$SSH_HOST" "curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:4434/health" 2>/dev/null || echo "000") + echo " 蓝色健康: HTTP $blue_ok" + echo " 绿色健康: HTTP $green_ok" + echo "" +} + +# ---------- 切换 ---------- +switch_to() { + local target="$1" + local current; current=$(get_active) + + [ "$target" = "$current" ] && { warn "已经是 ${target} 环境,无需切换"; return 0; } + + local target_port="" + [ "$target" = "blue" ] && target_port="4433" + [ "$target" = "green" ] && target_port="4434" + + echo "" + echo "═══════════════════════════════════════" + echo " 切换: ${current} → ${target} (:${target_port})" + echo "═══════════════════════════════════════" + echo "" + + # 先验证目标环境健康 + echo "验证目标环境健康..." + local target_ok; target_ok=$(ssh "$SSH_HOST" "curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:${target_port}/health" 2>/dev/null || echo "000") + if [ "$target_ok" != "200" ]; then + error "目标环境 :${target_port} 健康检查失败 (HTTP $target_ok),取消切换" + exit 1 + fi + info "目标环境健康 ✅" + + # 修改 proxy_pass + info "修改 OpenResty proxy_pass → :${target_port}..." + ssh "$SSH_HOST" "docker exec ${NGINX_CONTAINER} sh -c \"sed -i 's|proxy_pass http://127.0.0.1:[0-9]*|proxy_pass http://127.0.0.1:${target_port}|' ${ROOT_CONF}\"" 2>&1 || { + error "修改 proxy_pass 失败" + exit 1 + } + + # 重新加载 nginx + info "重新加载 nginx..." + ssh "$SSH_HOST" "docker exec ${NGINX_CONTAINER} nginx -s reload" 2>&1 || { + error "nginx 重载失败" + exit 1 + } + + # 验证切换后 + sleep 2 + local verify; verify=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "https://xmclassmate.top/health" 2>/dev/null || echo "000") + if [ "$verify" = "200" ]; then + info "切换完成 ✅ xmclassmate.top → :${target_port} (HTTP $verify)" + else + warn "切换完成,但 xmclassmate.top 返回 HTTP $verify(可能是旧代码无 /health 端点)" + fi +} + +# ---------- 主流程 ---------- +[ $# -eq 0 ] && { show_status; exit 0; } + +case "$1" in + --status) show_status ;; + --switch) [ -z "${2:-}" ] && { error "请指定 blue 或 green"; exit 1; } + case "$2" in + blue|green) switch_to "$2" ;; + *) error "参数错误: $2 (应为 blue 或 green)" ;; + esac ;; + --help|-h) usage ;; + *) error "未知选项: $1"; usage ;; +esac diff --git a/src/main.rs b/src/main.rs index 540e022..b10f079 100644 --- a/src/main.rs +++ b/src/main.rs @@ -276,7 +276,15 @@ async fn main() -> std::io::Result<()> { info!("Attempting to start server..."); let is_production = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()) == "production"; - let ports: Vec = if is_production { + + // 端口优先级:SERVER_PORT 环境变量 > 环境默认 + let ports: Vec = if let Ok(port_str) = std::env::var("SERVER_PORT") { + if let Ok(port) = port_str.parse::() { + vec![port] + } else { + vec![] + } + } else if is_production { vec![4433] } else { vec![8080, 3000]