#!/usr/bin/env bash # verify.sh — 私有化部署一键验证脚本 # # 用途: # 部署完成后,全面验证所有服务是否正常运行,输出通过/失败清单。 # 可独立运行,也可作为 deploy.sh 的最终步骤。 # # 使用方法: # # 从部署目录读取配置自动推断 # ./scripts/verify.sh # # # 指定部署地址(可覆盖 .env 中的值) # DEPLOY_HOST=192.168.1.100 ./scripts/verify.sh # # # 指定完整 URL # BASE_URL=http://192.168.1.100 ./scripts/verify.sh # # 验证项目: # 1. Docker 容器运行状态 # 2. 数据库和 Redis 连通性 # 3. tenant-service 健康(actuator/health) # 4. PRIVATE 模式激活 # 5. 注册接口已阻断 # 6. 前端页面可访问 # 7. SDK 配置接口 # 8. IM 服务(如已启动) # 9. 版本管理服务(如已启动) # 10. 文档站(如已启动) # 11. 租户登录和中文字符集 # # 退出码: # 0 = 全部通过 # 1 = 有项目失败 set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" # --------------------------------------------------------------------------- # 配置读取 # --------------------------------------------------------------------------- # 优先使用环境变量,否则从 .env 读取 if [ -f "$ROOT_DIR/.env" ]; then # shellcheck disable=SC1090 set -a; . "$ROOT_DIR/.env" 2>/dev/null; set +a fi DEPLOY_HOST="${DEPLOY_HOST:-127.0.0.1}" BASE_URL="${BASE_URL:-http://${DEPLOY_HOST}}" # 去掉末尾斜杠 BASE_URL="${BASE_URL%/}" MYSQL_PASSWORD="${MYSQL_PASSWORD:-}" REDIS_PASSWORD="${REDIS_PASSWORD:-}" # --------------------------------------------------------------------------- # 颜色和工具函数 # --------------------------------------------------------------------------- RED='\033[1;31m'; GREEN='\033[1;32m'; YELLOW='\033[1;33m' CYAN='\033[1;36m'; BOLD='\033[1m'; RESET='\033[0m' PASS=0; FAIL=0; WARN=0 RESULTS=() # 收集结果用于最终报告 pass() { local msg="$1" printf " ${GREEN}✓${RESET} PASS %s\n" "$msg" PASS=$((PASS+1)) RESULTS+=("PASS|$msg") } fail() { local msg="$1" detail="${2:-}" printf " ${RED}✗${RESET} FAIL %s" "$msg" [ -n "$detail" ] && printf " ${YELLOW}(%s)${RESET}" "$detail" printf "\n" FAIL=$((FAIL+1)) RESULTS+=("FAIL|$msg") } warn() { local msg="$1" detail="${2:-}" printf " ${YELLOW}⚠${RESET} WARN %s" "$msg" [ -n "$detail" ] && printf " (%s)" "$detail" printf "\n" WARN=$((WARN+1)) RESULTS+=("WARN|$msg") } section() { printf "\n${CYAN}── %s ${RESET}\n" "$1" } # HTTP 请求(自动跳过代理,直连内网) http_get() { local url="$1" timeout="${2:-8}" curl -sL --noproxy '*' -o /dev/null -w '%{http_code}' --max-time "$timeout" "$url" 2>/dev/null || echo 000 } http_body() { local url="$1" timeout="${2:-8}" curl -sL --noproxy '*' --max-time "$timeout" "$url" 2>/dev/null || true } http_body_auth() { local url="$1" token="$2" timeout="${3:-8}" curl -sL --noproxy '*' --max-time "$timeout" \ -H "Authorization: Bearer $token" "$url" 2>/dev/null || true } http_code_auth() { local url="$1" token="$2" timeout="${3:-8}" curl -sL --noproxy '*' -o /dev/null -w '%{http_code}' --max-time "$timeout" \ -H "Authorization: Bearer $token" "$url" 2>/dev/null || echo 000 } # --------------------------------------------------------------------------- # 开始验证 # --------------------------------------------------------------------------- printf "\n${BOLD}════════════════════════════════════════════════════${RESET}\n" printf "${BOLD} XuqmGroup 私有化部署验证${RESET}\n" printf "${BOLD}════════════════════════════════════════════════════${RESET}\n" printf " 部署地址: ${BOLD}%s${RESET}\n" "$BASE_URL" printf " 部署目录: %s\n" "$ROOT_DIR" printf " 验证时间: %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" # --------------------------------------------------------------------------- # 1. Docker 容器状态 # --------------------------------------------------------------------------- section "1. 容器状态" REQUIRED_CONTAINERS="tenant-service nginx tenant-web ops-web" for CTR_SUFFIX in $REQUIRED_CONTAINERS; do CTR_NAME=$(docker ps --filter "name=$CTR_SUFFIX" --filter "status=running" --format '{{.Names}}' 2>/dev/null | head -1) if [ -n "$CTR_NAME" ]; then pass "容器运行中: $CTR_NAME" else fail "容器未运行: *-${CTR_SUFFIX}-*" fi done OPTIONAL_CONTAINERS="file-service im-service push-service update-service license-service docs-site mysql redis" for CTR_SUFFIX in $OPTIONAL_CONTAINERS; do CTR_NAME=$(docker ps --filter "name=$CTR_SUFFIX" --filter "status=running" --format '{{.Names}}' 2>/dev/null | head -1) if [ -n "$CTR_NAME" ]; then pass "容器运行中: $CTR_NAME" else warn "容器未运行(可选): *-${CTR_SUFFIX}-*" fi done # --------------------------------------------------------------------------- # 2. 数据库和 Redis # --------------------------------------------------------------------------- section "2. 数据库和缓存" # MySQL MYSQL_CTR=$(docker ps --filter "name=mysql" --filter "status=running" --format '{{.Names}}' 2>/dev/null | head -1) if [ -n "$MYSQL_CTR" ]; then if docker exec "$MYSQL_CTR" mysqladmin -u root -p"${MYSQL_ROOT_PASSWORD:-}" ping --silent 2>/dev/null; then pass "MySQL 响应 PONG" else fail "MySQL ping 失败" fi else warn "MySQL 容器未运行(external 模式跳过此项)" fi # Redis REDIS_CTR=$(docker ps --filter "name=redis" --filter "status=running" --format '{{.Names}}' 2>/dev/null | head -1) if [ -n "$REDIS_CTR" ]; then PING=$(docker exec "$REDIS_CTR" redis-cli -a "${REDIS_PASSWORD:-}" --no-auth-warning PING 2>/dev/null | tr -d '\r') if [ "$PING" = "PONG" ]; then pass "Redis 响应 PONG" else fail "Redis ping 失败 (got: $PING)" fi else warn "Redis 容器未运行(external 模式跳过此项)" fi # --------------------------------------------------------------------------- # 3. 核心服务健康 # --------------------------------------------------------------------------- section "3. 核心服务" # actuator/health HEALTH_BODY=$(http_body "$BASE_URL/actuator/health") HEALTH_STATUS=$(echo "$HEALTH_BODY" | grep -o '"status":"[^"]*"' | head -1) if [ "$HEALTH_STATUS" = '"status":"UP"' ]; then pass "tenant-service actuator/health = UP" else fail "tenant-service 未就绪" "$(echo "$HEALTH_BODY" | head -c 100)" fi # 私有化模式 DEPLOY_STATUS=$(http_body "$BASE_URL/api/private/deployment/status") if echo "$DEPLOY_STATUS" | grep -q '"mode":"PRIVATE"'; then pass "PRIVATE 模式已激活" else fail "PRIVATE 模式未激活" "$(echo "$DEPLOY_STATUS" | head -c 100)" fi if echo "$DEPLOY_STATUS" | grep -q '"tenantRegisterEnabled":false'; then pass "租户注册已禁用" else fail "租户注册未禁用(安全风险)" fi # 注册接口阻断 REG_CODE=$(curl -sL --noproxy '*' -o /dev/null -w '%{http_code}' --max-time 8 \ -X POST "$BASE_URL/api/auth/register" \ -H 'Content-Type: application/json' \ -d '{"email":"verify-test@block.test","username":"verifytest","password":"Test@123456"}' 2>/dev/null || echo 000) if [ "$REG_CODE" != "200" ] && [ "$REG_CODE" != "000" ]; then pass "注册接口已阻断 (HTTP $REG_CODE)" else fail "注册接口未阻断 (HTTP $REG_CODE)" fi # --------------------------------------------------------------------------- # 4. 前端页面 # --------------------------------------------------------------------------- section "4. 前端页面" WEB_CODE=$(http_get "$BASE_URL/") if [ "$WEB_CODE" = "200" ]; then pass "控制台前端可访问 (HTTP 200)" else fail "控制台前端不可访问 (HTTP $WEB_CODE)" fi OPS_CODE=$(http_get "$BASE_URL/ops") if echo "$OPS_CODE" | grep -qE '^(200|301|302)$'; then pass "运营后台可访问 (HTTP $OPS_CODE)" else fail "运营后台不可访问 (HTTP $OPS_CODE)" fi DOCS_CODE=$(http_get "$BASE_URL/docs/") if echo "$DOCS_CODE" | grep -qE '^(200|301|302)$'; then pass "文档站可访问 (HTTP $DOCS_CODE)" else warn "文档站不可访问 (HTTP $DOCS_CODE)(可选服务)" fi # --------------------------------------------------------------------------- # 5. 租户登录和中文字符集 # --------------------------------------------------------------------------- section "5. 租户登录" # 获取验证码 CAPTCHA_RESP=$(http_body "$BASE_URL/api/auth/captcha") CAPTCHA_KEY=$(echo "$CAPTCHA_RESP" | grep -o '"key":"[^"]*"' | head -1 | cut -d'"' -f4) LOGIN_OK=false TOKEN="" NICK="" if [ -n "$CAPTCHA_KEY" ]; then # 从 Redis 读取验证码 REDIS_CTR=$(docker ps --filter "name=redis" --filter "status=running" --format '{{.Names}}' 2>/dev/null | head -1) if [ -n "$REDIS_CTR" ]; then CAPTCHA_CODE=$(docker exec "$REDIS_CTR" redis-cli -a "${REDIS_PASSWORD:-}" --no-auth-warning \ GET "captcha:${CAPTCHA_KEY}" 2>/dev/null | tr -d '\r') if [ -n "$CAPTCHA_CODE" ]; then # 尝试登录(用 bootstrap 邮箱) BOOTSTRAP_EMAIL="${TENANT_BOOTSTRAP_EMAIL:-admin@company.com}" BOOTSTRAP_PASS="${TENANT_BOOTSTRAP_PASSWORD:-}" LOGIN_RESP=$(curl -sL --noproxy '*' --max-time 10 \ -X POST "$BASE_URL/api/auth/login" \ -H 'Content-Type: application/json' \ -d "{\"account\":\"${BOOTSTRAP_EMAIL}\",\"password\":\"${BOOTSTRAP_PASS}\",\"captchaKey\":\"${CAPTCHA_KEY}\",\"captchaCode\":\"${CAPTCHA_CODE}\"}" 2>/dev/null || true) LOGIN_CODE=$(echo "$LOGIN_RESP" | grep -o '"code":[0-9]*' | head -1 | grep -o '[0-9]*') if [ "$LOGIN_CODE" = "200" ]; then TOKEN=$(echo "$LOGIN_RESP" | grep -o '"token":"[^"]*"' | cut -d'"' -f4) LOGIN_OK=true pass "租户登录成功" elif [ "$BOOTSTRAP_PASS" = "change-me-on-first-login" ] || [ -z "$BOOTSTRAP_PASS" ]; then # bootstrap 密码是占位符,说明已迁移生产数据,用生产密码登录,此处跳过 warn "租户登录跳过(bootstrap 密码为占位符,迁移后请用生产密码验证)" # 解码 JWT 检查中文字符集 if command -v python3 >/dev/null 2>&1 && [ -n "$TOKEN" ]; then NICK=$(python3 -c " import base64, json, sys try: payload = '${TOKEN}'.split('.')[1] payload += '=' * (4 - len(payload) % 4) d = json.loads(base64.urlsafe_b64decode(payload).decode('utf-8')) print(d.get('nickname','')) except: print('') " 2>/dev/null) if [ -n "$NICK" ] && echo "$NICK" | python3 -c "import sys; s=sys.stdin.read().strip(); sys.exit(0 if s == s.encode('utf-8').decode('utf-8') and any(ord(c) > 0x7F for c in s) else 0)" 2>/dev/null; then pass "JWT 中文字段正常: $NICK" elif [ -n "$NICK" ]; then pass "JWT 昵称字段: $NICK" else warn "JWT 昵称字段为空(bootstrap 租户可能未迁移)" fi fi else fail "租户登录失败" "code=$LOGIN_CODE (resp: $(echo "$LOGIN_RESP" | head -c 80))" fi else warn "无法从 Redis 读取验证码(跳过登录测试)" fi else warn "Redis 容器未运行(跳过登录测试)" fi else fail "无法获取验证码(captcha API 异常)" fi # --------------------------------------------------------------------------- # 6. SDK 配置接口 # --------------------------------------------------------------------------- section "6. SDK 配置" if [ -f "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" ]; then DEFAULT_APP_KEY=$(python3 -c " import json try: d = json.load(open('$ROOT_DIR/config/sdk/xuqm-private-sdk.json')) print(d.get('appKey','')) except: print('') " 2>/dev/null) fi # 从数据库查询已有 App MYSQL_CTR=$(docker ps --filter "name=mysql" --filter "status=running" --format '{{.Names}}' 2>/dev/null | head -1) APP_KEYS="" if [ -n "$MYSQL_CTR" ]; then APP_KEYS=$(docker exec "$MYSQL_CTR" sh -c \ "mysql -u${MYSQL_USERNAME:-xuqm} -p${MYSQL_PASSWORD:-} ${MYSQL_DATABASE:-xuqm_private} -N \ -e 'SELECT app_key FROM t_app LIMIT 5;'" 2>/dev/null | tr '\n' ' ') fi if [ -z "$APP_KEYS" ] && [ -n "${DEFAULT_APP_KEY:-}" ]; then APP_KEYS="$DEFAULT_APP_KEY" fi if [ -n "$APP_KEYS" ]; then for APP_KEY in $APP_KEYS; do SDK_CODE=$(http_get "$BASE_URL/api/sdk/config?appKey=${APP_KEY}&platform=ANDROID") SDK_BODY=$(http_body "$BASE_URL/api/sdk/config?appKey=${APP_KEY}&platform=ANDROID") if [ "$SDK_CODE" = "200" ] && echo "$SDK_BODY" | grep -q '"code":200'; then pass "SDK config: $APP_KEY" else fail "SDK config: $APP_KEY (HTTP $SDK_CODE)" fi done else warn "未找到 App Key(数据库可能为空或尚未迁移)" fi # --------------------------------------------------------------------------- # 7. 可选服务 # --------------------------------------------------------------------------- section "7. 可选服务(需认证,401 = 正常)" check_optional_service() { local label="$1" url="$2" token="$3" local code if [ -n "$token" ]; then code=$(http_code_auth "$url" "$token") else code=$(http_get "$url") fi if echo "$code" | grep -qE '^(200|201|400|401|403|404)$'; then pass "$label 响应正常 (HTTP $code)" elif [ "$code" = "000" ]; then warn "$label 无法连接(未部署或未启动)" else fail "$label 返回错误 (HTTP $code)" fi } check_optional_service "IM 服务" \ "$BASE_URL/api/im/platform-events/token?appKey=test" "$TOKEN" check_optional_service "版本管理 (update)" \ "$BASE_URL/api/v1/updates/app/list?appKey=test&platform=ANDROID" "$TOKEN" check_optional_service "RN 热更新 (rn)" \ "$BASE_URL/api/v1/rn/list?appKey=test" "$TOKEN" # 文件服务 FILE_CODE=$(http_get "$BASE_URL/file/") if echo "$FILE_CODE" | grep -qE '^(200|400|401|403|404)$'; then pass "文件服务响应正常 (HTTP $FILE_CODE)" else warn "文件服务响应: HTTP $FILE_CODE" fi # --------------------------------------------------------------------------- # 最终汇总 # --------------------------------------------------------------------------- printf "\n${BOLD}════════════════════════════════════════════════════${RESET}\n" printf "${BOLD} 验证结果${RESET}\n" printf "${BOLD}════════════════════════════════════════════════════${RESET}\n" printf " ${GREEN}PASS${RESET}: %d 项\n" "$PASS" printf " ${YELLOW}WARN${RESET}: %d 项(可选服务或预期降级)\n" "$WARN" printf " ${RED}FAIL${RESET}: %d 项\n" "$FAIL" if [ "$FAIL" -eq 0 ]; then printf "\n ${GREEN}${BOLD}✓ 验证通过!所有必选服务运行正常。${RESET}\n" [ "$WARN" -gt 0 ] && printf " ${YELLOW}(%d 个可选服务未启动,属于预期范围)${RESET}\n" "$WARN" else printf "\n ${RED}${BOLD}✗ %d 项验证失败,请检查容器日志:${RESET}\n" "$FAIL" printf " docker compose logs --tail 50 tenant-service\n" fi printf "\n 访问地址:${BOLD}%s${RESET}\n" "$BASE_URL" printf " 运营后台:${BOLD}%s/ops${RESET}\n" "$BASE_URL" printf " 文档站 :${BOLD}%s/docs/${RESET}\n" "$BASE_URL" printf "\n" # 输出结果到 JSON RESULT_JSON="$ROOT_DIR/.deploy-state/last-verify.json" { printf '{"timestamp":"%s","base_url":"%s","pass":%d,"warn":%d,"fail":%d,"items":[' \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$BASE_URL" "$PASS" "$WARN" "$FAIL" first=true for r in "${RESULTS[@]}"; do STATUS="${r%%|*}"; MSG="${r##*|}" $first || printf ',' printf '{"status":"%s","message":"%s"}' "$STATUS" "$MSG" first=false done printf ']}\n' } > "$RESULT_JSON" 2>/dev/null || true [ "$FAIL" -eq 0 ]