XuqmGroup-PrivateDeploy/scripts/verify.sh
徐勤民 a6a81b0755 feat(deploy): generic deploy.sh with API-based tenant migration
- Rename deploy-szyx.sh → deploy.sh, remove all customer-specific branding
- Migrate mode: prompt for pmk_ key, call public platform export API,
  pipe to private import API — no MySQL credentials needed
- Remove bcrypt dependency (no longer used in script logic)
- Update install.sh and verify.sh references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:14:23 +08:00

445 行
16 KiB
Bash

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

#!/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 ]