#!/usr/bin/env bash # deploy-szyx.sh — 数字医信私有化一键部署脚本 # # 用途:在目标机器上完成以下全部步骤: # 1. 预检(Docker、Compose、磁盘、端口) # 2. 写入数字医信专属配置(.env / secrets.env / xuqm.env / nginx) # 3. 登录 ACR 镜像仓库 # 4. 启动基础设施容器(MySQL、Redis)并等待就绪 # 5. 启动业务容器(base profile)并等待健康 # 6. 迁移数字医信生产租户数据 # 7. 最终验收并输出登录指引 # # 幂等性:可重复执行。已运行的容器不会被重建;已迁移的租户不会被二次清空。 # # 前提: # - Docker 和 Docker Compose v2 已安装 # - 目标机器与生产 MySQL (39.107.53.187) 网络可达 # - 在 XuqmGroup-PrivateDeploy 仓库根目录下执行本脚本 # 或通过 ssh 执行:sshpass -p '...' ssh xuqm@HOST 'cd /opt/xuqm-private && bash scripts/deploy-szyx.sh' # # 覆盖默认值(可通过环境变量传入): # DEPLOY_HOST 目标机器 IP / 主机名,用于验收 HTTP 检查(默认 127.0.0.1) # REGISTRY_PASSWORD ACR 密码(默认 xuqinmin1022) # MYSQL_ROOT_PASSWORD、MYSQL_PASSWORD、REDIS_PASSWORD 同理 set -euo pipefail # --------------------------------------------------------------------------- # 常量 — 数字医信专属配置 # --------------------------------------------------------------------------- CUSTOMER_NAME="数字医信" CUSTOMER_SHORT="szyx" # 镜像仓库 REGISTRY="crpi-n44qjpuucgjt8e8c.cn-beijing.personal.cr.aliyuncs.com/xuqmgroup" REGISTRY_HOST="crpi-n44qjpuucgjt8e8c.cn-beijing.personal.cr.aliyuncs.com" REGISTRY_USER="xuqinmin12" REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-xuqinmin1022}" IMAGE_TAG="latest" # 部署主机(用于健康检查 HTTP 请求) DEPLOY_HOST="${DEPLOY_HOST:-127.0.0.1}" CONSOLE_BASE="http://${DEPLOY_HOST}" # MySQL(managed 模式,由 Docker 容器托管) MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-XuqmRoot@2026}" MYSQL_PASSWORD="${MYSQL_PASSWORD:-XuqmMysql@2026}" MYSQL_DATABASE="xuqm_private" MYSQL_USERNAME="xuqm" # Redis(managed 模式) REDIS_PASSWORD="${REDIS_PASSWORD:-XuqmRedis@2026}" # 数字医信登录密码(不在脚本里重置,使用生产密码原文迁移) TENANT_EMAIL="szyx@bjca.org.cn" TENANT_USERNAME="szyx" # 源生产 MySQL SRC_HOST="39.107.53.187" SRC_PORT="3306" SRC_USER="xuqm" SRC_PASSWORD="Xuqm@2026" SRC_DB="xuqm_tenant" # --------------------------------------------------------------------------- # 内部变量 # --------------------------------------------------------------------------- ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" STEP=0 # --------------------------------------------------------------------------- # 工具函数 # --------------------------------------------------------------------------- log() { printf '\n\033[1;36m[%d/%d] %s\033[0m\n' "$STEP" "$TOTAL_STEPS" "$*"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } warn() { printf ' \033[33m⚠\033[0m %s\n' "$*"; } fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } step() { STEP=$((STEP+1)); log "$*"; } wait_http() { local url="$1" max="${2:-120}" interval=3 local waited=0 while [ "$waited" -lt "$max" ]; do code="$(curl -skL -o /dev/null -w '%{http_code}' --max-time 4 "$url" 2>/dev/null || echo 000)" [ "$code" = "200" ] || [ "$code" = "204" ] && return 0 sleep "$interval" waited=$((waited + interval)) printf ' waiting %s ... HTTP %s\n' "$url" "$code" done return 1 } container_running() { docker ps --filter "name=$1" --filter "status=running" --format '{{.Names}}' 2>/dev/null | grep -q "$1" } TOTAL_STEPS=7 # --------------------------------------------------------------------------- # 0. 确认工作目录 # --------------------------------------------------------------------------- [ -f "$ROOT_DIR/docker-compose.yml" ] || \ fail "请在 XuqmGroup-PrivateDeploy 仓库根目录执行本脚本(当前: $ROOT_DIR)" cd "$ROOT_DIR" printf '\n\033[1;35m══════════════════════════════════════════════════\033[0m\n' printf '\033[1;35m %s 私有化一键部署脚本\033[0m\n' "$CUSTOMER_NAME" printf '\033[1;35m══════════════════════════════════════════════════\033[0m\n' printf ' 部署目录: %s\n' "$ROOT_DIR" printf ' 目标主机: %s\n' "$DEPLOY_HOST" printf ' 镜像仓库: %s\n' "$REGISTRY" printf ' 租户: %s (%s)\n\n' "$CUSTOMER_NAME" "$TENANT_EMAIL" # --------------------------------------------------------------------------- # Step 1 — 预检 # --------------------------------------------------------------------------- step "预检(Docker / Compose / 磁盘 / 端口)" command -v docker >/dev/null 2>&1 || fail "Docker 未安装" docker info >/dev/null 2>&1 || fail "Docker daemon 未运行" ok "Docker $(docker --version | grep -o '[0-9]*\.[0-9]*\.[0-9]*' | head -1)" docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 未安装" ok "Docker Compose $(docker compose version --short 2>/dev/null || echo 'v2')" command -v mysql >/dev/null 2>&1 || fail "mysql 客户端未安装(迁移步骤需要): apt install -y mysql-client" ok "mysql 客户端已安装" DISK_FREE_GB="$(df -BG "$ROOT_DIR" | awk 'NR==2{gsub(/G/,"",$4); print $4}')" [ "${DISK_FREE_GB:-0}" -ge 10 ] || \ fail "磁盘可用空间不足(需 ≥10 GB,当前 ${DISK_FREE_GB:-?} GB)" ok "磁盘可用: ${DISK_FREE_GB} GB" for port in 80 443; do if ss -tlnp 2>/dev/null | grep -q ":${port} " || \ netstat -tlnp 2>/dev/null | grep -q ":${port} "; then warn "端口 ${port} 已被占用(继续执行,如已是本脚本容器则无影响)" else ok "端口 ${port} 空闲" fi done # --------------------------------------------------------------------------- # Step 2 — 写入配置 # --------------------------------------------------------------------------- step "写入数字医信专属配置" # .env cat > "$ROOT_DIR/.env" < "$ROOT_DIR/config/secrets.env" < "$ROOT_DIR/config/xuqm.env" < "$ROOT_DIR/config/tenant/bootstrap.env" < "$ROOT_DIR/config/nginx/conf.d/xuqm.conf" <<'NGINX_CONF' server { listen 80; server_name _; client_max_body_size 100m; location /health { return 200 "ok\n"; add_header Content-Type text/plain; } # tenant-service 运行在 9001 端口 location /api/ { proxy_pass http://tenant-service:9001/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 60s; } location /actuator/ { proxy_pass http://tenant-service:9001/actuator/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # file-service 运行在 8086 端口 location /file/ { proxy_pass http://file-service:8086/file/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; client_max_body_size 500m; proxy_read_timeout 300s; } location /ops { proxy_pass http://ops-web:80; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://tenant-web:80; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } NGINX_CONF ok "config/nginx/conf.d/xuqm.conf 已写入" # 生成 SDK JSON 和 docs-runtime.json mkdir -p "$ROOT_DIR/config/sdk" "$ROOT_DIR/config/docs" "$ROOT_DIR/.deploy-state" cat > "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" < "$ROOT_DIR/config/docs/docs-runtime.json" </dev/null \ && ok "登录成功: $REGISTRY_HOST" \ || fail "ACR 登录失败,请检查 REGISTRY_USER / REGISTRY_PASSWORD" # --------------------------------------------------------------------------- # Step 4 — 启动基础设施(MySQL + Redis) # --------------------------------------------------------------------------- step "启动基础设施容器(MySQL / Redis)" docker compose \ --env-file "$ROOT_DIR/.env" \ -f "$ROOT_DIR/docker-compose.yml" \ -f "$ROOT_DIR/docker-compose.infra.yml" \ --profile infra-mysql --profile infra-redis \ up -d mysql redis 2>/dev/null || \ docker compose \ --env-file "$ROOT_DIR/.env" \ -f "$ROOT_DIR/docker-compose.yml" \ --profile infra-mysql --profile infra-redis \ up -d mysql redis # 等待 MySQL 就绪(最多 90 秒) printf ' 等待 MySQL 启动' for i in $(seq 1 30); do if docker exec "$(docker ps -qf name=mysql | head -1)" \ mysqladmin -u root -p"${MYSQL_ROOT_PASSWORD}" ping --silent 2>/dev/null; then printf '\n' ok "MySQL 就绪" break fi printf '.' sleep 3 [ "$i" -eq 30 ] && { printf '\n'; fail "MySQL 启动超时(90s)"; } done # 等待 Redis 就绪 for i in $(seq 1 10); do if docker exec "$(docker ps -qf name=redis | head -1)" \ redis-cli -a "$REDIS_PASSWORD" --no-auth-warning PING 2>/dev/null | grep -q PONG; then ok "Redis 就绪" break fi sleep 2 [ "$i" -eq 10 ] && fail "Redis 启动超时(20s)" done # --------------------------------------------------------------------------- # Step 5 — 启动业务容器 # --------------------------------------------------------------------------- step "拉取镜像并启动业务容器(base profile)" docker compose \ --env-file "$ROOT_DIR/.env" \ -f "$ROOT_DIR/docker-compose.yml" \ -f "$ROOT_DIR/docker-compose.infra.yml" \ --profile base --profile infra-mysql --profile infra-redis \ up -d 2>/dev/null || \ docker compose \ --env-file "$ROOT_DIR/.env" \ -f "$ROOT_DIR/docker-compose.yml" \ --profile base --profile infra-mysql --profile infra-redis \ up -d # 等待 tenant-service 健康(最多 120 秒) printf ' 等待 tenant-service 启动' for i in $(seq 1 40); do code="$(curl -skL -o /dev/null -w '%{http_code}' --max-time 4 \ "http://${DEPLOY_HOST}/actuator/health" 2>/dev/null || echo 000)" if [ "$code" = "200" ]; then printf '\n' ok "tenant-service 健康 (HTTP 200)" break fi printf '.' sleep 3 [ "$i" -eq 40 ] && { printf '\n' warn "tenant-service 未在 120s 内响应,继续执行(可能仍在初始化)" break } done # 简要容器状态 printf '\n 容器状态:\n' docker compose \ --env-file "$ROOT_DIR/.env" \ -f "$ROOT_DIR/docker-compose.yml" \ -f "$ROOT_DIR/docker-compose.infra.yml" \ ps --format 'table {{.Name}}\t{{.Status}}' 2>/dev/null \ || docker ps --format ' {{.Names}}\t{{.Status}}' | grep -E "xuqm|mysql|redis" || true # --------------------------------------------------------------------------- # Step 6 — 迁移数字医信租户 # --------------------------------------------------------------------------- step "迁移数字医信租户数据(从生产 MySQL 导入)" # 检查是否已迁移(目标 DB 中是否已有数字医信的租户记录) MYSQL_CTR="$(docker ps -qf name=mysql | head -1)" EXISTING="$(docker exec "$MYSQL_CTR" \ mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \ -N -e "SELECT COUNT(*) FROM t_tenant WHERE email='${TENANT_EMAIL}'" 2>/dev/null || echo 0)" if [ "${EXISTING:-0}" -ge 1 ]; then ok "数字医信租户已存在于私有化 DB,跳过迁移" else printf ' 连通性检查生产 MySQL %s ...\n' "$SRC_HOST" MYSQL_PWD="$SRC_PASSWORD" mysql \ -h "$SRC_HOST" -P "$SRC_PORT" -u "$SRC_USER" \ --connect-timeout=10 "$SRC_DB" \ -e "SELECT 1" >/dev/null 2>&1 \ || fail "无法连接生产 MySQL ${SRC_HOST}:${SRC_PORT},请检查网络或凭据" ok "生产 MySQL 连通" bash "$ROOT_DIR/scripts/migrate-tenant.sh" \ --src-host "$SRC_HOST" \ --src-port "$SRC_PORT" \ --src-user "$SRC_USER" \ --src-password "$SRC_PASSWORD" \ --src-db "$SRC_DB" \ --tenant "$TENANT_EMAIL" fi # --------------------------------------------------------------------------- # Step 7 — 最终验收 # --------------------------------------------------------------------------- step "最终验收" PASS=0 FAIL=0 check() { local label="$1" actual="$2" expected="$3" if printf '%s' "$actual" | grep -q "$expected"; then ok "PASS $label" PASS=$((PASS+1)) else warn "FAIL $label (got: $actual)" FAIL=$((FAIL+1)) fi } # actuator/health HEALTH="$(curl -skL --max-time 5 "http://${DEPLOY_HOST}/actuator/health" 2>/dev/null || true)" check "actuator/health" "$HEALTH" '"status":"UP"' # PRIVATE 模式 STATUS="$(curl -skL --max-time 5 "http://${DEPLOY_HOST}/api/private/deployment/status" 2>/dev/null || true)" check "deployment mode=PRIVATE" "$STATUS" '"mode":"PRIVATE"' check "tenantRegisterEnabled=false" "$STATUS" '"tenantRegisterEnabled":false' # 数字医信两个 App SDK config for APPKEY in ak_c6fce237cae94ef5ab71fda6 ak_1178fd37b8f54cefb7031744; do SDK="$(curl -skL --max-time 5 \ "http://${DEPLOY_HOST}/api/sdk/config?appKey=${APPKEY}&platform=ANDROID" 2>/dev/null || true)" check "sdk/config $APPKEY" "$SDK" '"code":200' done # 注册阻断 REG="$(curl -skL --max-time 5 -o /dev/null -w '%{http_code}' \ -X POST "http://${DEPLOY_HOST}/api/auth/register" \ -H 'Content-Type: application/json' \ -d '{"email":"blocked@test.com","username":"blocked","password":"Test@123"}' 2>/dev/null || echo 000)" # 注册应返回 4xx(阻断)或 400(字段校验),不应返回 200 if [ "$REG" != "200" ] && [ "$REG" != "000" ]; then ok "PASS 注册阻断 (HTTP $REG)" PASS=$((PASS+1)) else warn "FAIL 注册阻断返回 $REG" FAIL=$((FAIL+1)) fi # 前端可访问 WEB="$(curl -skL -o /dev/null -w '%{http_code}' --max-time 5 "http://${DEPLOY_HOST}/" 2>/dev/null || echo 000)" check "前端 HTTP" "$WEB" "200" # --------------------------------------------------------------------------- # 结果汇总 # --------------------------------------------------------------------------- printf '\n\033[1;35m══════════════════════════════════════════════════\033[0m\n' printf '\033[1;35m 验收结果: %d PASS / %d FAIL\033[0m\n' "$PASS" "$FAIL" printf '\033[1;35m══════════════════════════════════════════════════\033[0m\n' printf '\n \033[1m访问地址:\033[0m %s\n' "${CONSOLE_BASE}" printf ' \033[1m运营后台:\033[0m %s/ops\n' "${CONSOLE_BASE}" printf ' \033[1m租户账号:\033[0m %s\n' "${TENANT_EMAIL}" printf ' \033[1m租户用户名:\033[0m %s\n' "${TENANT_USERNAME}" printf ' \033[1m密码:\033[0m 同公有化平台密码(未重置)\n' printf '\n \033[1m应用列表:\033[0m\n' printf ' 医网信 ak_c6fce237cae94ef5ab71fda6\n' printf ' 临床知识库 ak_1178fd37b8f54cefb7031744\n' printf '\n \033[1m部署目录:\033[0m %s\n' "$ROOT_DIR" printf ' \033[1m审计日志:\033[0m %s/logs/audit.log\n' "$ROOT_DIR" if [ "$FAIL" -gt 0 ]; then printf '\n\033[33m %d 项验收未通过,请查看容器日志:\033[0m\n' "$FAIL" printf ' docker compose logs --tail 50 tenant-service\n' exit 1 fi printf '\n\033[1;32m 部署成功!\033[0m\n\n'