2026-05-19 14:29:57 +08:00
#!/usr/bin/env bash
# verify.sh — 私有化部署一键验证脚本
#
# 用途:
# 部署完成后,全面验证所有服务是否正常运行,输出通过/失败清单。
2026-05-19 15:14:23 +08:00
# 可独立运行,也可作为 deploy.sh 的最终步骤。
2026-05-19 14:29:57 +08:00
#
# 使用方法:
# # 从部署目录读取配置自动推断
# ./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. 容器状态"
2026-05-20 18:25:12 +08:00
REQUIRED_CONTAINERS = "tenant-service nginx tenant-web"
2026-05-19 14:29:57 +08:00
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
2026-05-20 16:56:42 +08:00
OPTIONAL_CONTAINERS = "file-service im-service push-service update-service license-service mysql redis"
2026-05-19 14:29:57 +08:00
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
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 "
2026-05-19 17:01:58 +08:00
printf " ${ YELLOW } WARN ${ RESET } : %d 项(预期降级,不影响功能)\n " " $WARN "
2026-05-19 14:29:57 +08:00
printf " ${ RED } FAIL ${ RESET } : %d 项\n " " $FAIL "
if [ " $FAIL " -eq 0 ] ; then
printf " \n ${ GREEN } ${ BOLD } ✓ 验证通过!所有必选服务运行正常。 ${ RESET } \n "
2026-05-19 17:01:58 +08:00
[ " $WARN " -gt 0 ] && printf " ${ YELLOW } ( %d 项检查为预期降级,所有服务均已全量启动)${ RESET } \n " " $WARN "
2026-05-19 14:29:57 +08:00
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/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 ]