XuqmGroup-PrivateDeploy/scripts/deploy-szyx.sh
徐勤民 43a423b85c feat: 一键安装向导 + 交互式租户初始化
- 新增 install.sh:curl 一键下载依赖安装 + 自动解压部署包 + 启动部署向导
- deploy-szyx.sh:移除硬编码租户常量,改为交互式选择(新建/迁移)
  - 新建租户:收集邮箱/用户名/密码,bcrypt 写入 bootstrap.env
  - 迁移租户:提示输入生产 MySQL 配置,bcrypt 验证主账号后执行迁移
  - 已有数据时迁移前显示红色警告要求 yes 确认
- 移除 docs-site 独立容器(文档已内置于 tenant-web/docs/)
- nginx 和 docker-compose 同步清理 docs-site 残留配置

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

885 行
35 KiB
Bash
可执行文件

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

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

#!/usr/bin/env bash
# deploy-szyx.sh — 数字医信私有化一键部署脚本
#
# 用途:在目标机器上完成以下全部步骤:
# 0. 交互式选择租户初始化方式(新建 / 迁移)并收集必要信息
# 1. 预检Docker、Compose、python3-bcrypt、磁盘、端口
# 2. 写入配置(.env / secrets.env / xuqm.env / bootstrap.env / nginx
# 3. 登录 ACR 镜像仓库
# 4. 启动基础设施容器MySQL、Redis并等待就绪
# 5. 拉取镜像并启动所有业务容器base + im + push + update + license
# 6. Schema 扩展is_default / deletable 列 + 删除保护触发器)+ 系统应用初始化
# 7. 租户初始化(新建:验证 bootstrap 结果;迁移:执行 migrate-tenant.sh
# 8. 运行一键验证脚本确认所有服务正常
#
# 幂等性:可重复执行。已运行的容器不会被重建。
# 迁移模式:若检测到已有租户数据,会在执行前提示确认。
#
# 前提:
# - Docker 和 Docker Compose v2 已安装
# - python3 和 python3-bcrypt 已安装(用于密码哈希和认证)
# - 迁移模式还需要 mysql 客户端
# - 在 XuqmGroup-PrivateDeploy 仓库根目录下执行本脚本
#
# 覆盖默认值(可通过环境变量传入):
# DEPLOY_HOST 目标机器 IP / 主机名(默认 127.0.0.1
# 纯 IP 部署无需域名,局域网内可全功能使用
# 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 请求)
# 可以是 IP 地址,无需域名,局域网内即可完整使用所有服务
DEPLOY_HOST="${DEPLOY_HOST:-127.0.0.1}"
CONSOLE_BASE="http://${DEPLOY_HOST}"
# MySQLmanaged 模式,由 Docker 容器托管)
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-XuqmRoot@2026}"
MYSQL_PASSWORD="${MYSQL_PASSWORD:-XuqmMysql@2026}"
MYSQL_DATABASE="xuqm_private"
MYSQL_USERNAME="xuqm"
# Redismanaged 模式)
REDIS_PASSWORD="${REDIS_PASSWORD:-XuqmRedis@2026}"
# 源生产 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
TOTAL_STEPS=8
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
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 "$*"; }
container_running() {
docker ps --filter "name=$1" --filter "status=running" --format '{{.Names}}' 2>/dev/null | grep -q "$1"
}
# ---------------------------------------------------------------------------
# 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' "$CONSOLE_BASE"
printf ' 镜像仓库: %s\n' "$REGISTRY"
printf ' 服务集: base + im + push + update + license\n\n'
# ---------------------------------------------------------------------------
# 0b. 租户初始化方式选择(交互式,在 Step 1 预检前执行)
# ---------------------------------------------------------------------------
command -v python3 >/dev/null 2>&1 || \
fail "python3 未安装(租户密码哈希需要): apt install -y python3 python3-bcrypt"
python3 -c "import bcrypt" 2>/dev/null || \
fail "python3-bcrypt 未安装: pip3 install bcrypt 或 apt install -y python3-bcrypt"
printf '\033[1;33m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m\n'
printf '\033[1;33m 租户初始化方式\033[0m\n'
printf '\033[1;33m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m\n'
printf ' 1) 新建租户 — 填写租户信息,系统将为您创建全新账号\n'
printf ' 2) 迁移租户 — 从生产 MySQL 导入现有租户数据(需主账号认证)\n\n'
DEPLOY_MODE=""
while [ -z "$DEPLOY_MODE" ]; do
read -rp " 请选择 [1/2]: " _choice
case "$_choice" in
1) DEPLOY_MODE="new" ;;
2) DEPLOY_MODE="migrate" ;;
*) printf ' 请输入 1 或 2\n' ;;
esac
done
DEPLOY_TENANT_EMAIL=""
DEPLOY_TENANT_USERNAME=""
DEPLOY_TENANT_NICKNAME=""
_BOOTSTRAP_PASSWORD=""
if [ "$DEPLOY_MODE" = "new" ]; then
printf '\n ── 新建租户信息 ──\n'
while [ -z "$DEPLOY_TENANT_EMAIL" ]; do
read -rp " 租户邮箱: " DEPLOY_TENANT_EMAIL
printf '%s' "$DEPLOY_TENANT_EMAIL" | grep -qE '^[^@]+@[^@]+\.[^@]+$' || {
warn "邮箱格式不正确,请重新输入"
DEPLOY_TENANT_EMAIL=""
}
done
while [ -z "$DEPLOY_TENANT_USERNAME" ]; do
read -rp " 用户名(登录用): " DEPLOY_TENANT_USERNAME
done
read -rp " 显示名称(昵称,默认同用户名): " DEPLOY_TENANT_NICKNAME
[ -z "$DEPLOY_TENANT_NICKNAME" ] && DEPLOY_TENANT_NICKNAME="$DEPLOY_TENANT_USERNAME"
_pw=""
while [ -z "$_pw" ]; do
read -rsp " 登录密码≥8 位): " _pw; printf '\n'
[ "${#_pw}" -ge 8 ] || { warn "密码不能少于 8 位,请重新输入"; _pw=""; continue; }
read -rsp " 确认密码: " _pw2; printf '\n'
[ "$_pw" = "$_pw2" ] || { warn "两次密码不一致,请重新输入"; _pw=""; }
done
unset _pw2
# Spring Boot bootstrap 接收明文密码并在存储时自行 bcryptbootstrap.env chmod 600
_BOOTSTRAP_PASSWORD="$_pw"
unset _pw
ok "租户信息已收集"
else
# ── 迁移模式:先收集 SRC 配置 + 认证,再继续后续步骤 ──
printf '\n ── 迁移源 MySQL 配置(回车使用默认值)──\n'
read -rp " 源 MySQL 主机 [${SRC_HOST}]: " _inp; [ -n "$_inp" ] && SRC_HOST="$_inp"
read -rp " 源 MySQL 端口 [${SRC_PORT}]: " _inp; [ -n "$_inp" ] && SRC_PORT="$_inp"
read -rp " 源 MySQL 用户 [${SRC_USER}]: " _inp; [ -n "$_inp" ] && SRC_USER="$_inp"
read -rsp " 源 MySQL 密码 [(隐藏,回车保留默认)]: " _inp; printf '\n'; [ -n "$_inp" ] && SRC_PASSWORD="$_inp"
read -rp " 源数据库名 [${SRC_DB}]: " _inp; [ -n "$_inp" ] && SRC_DB="$_inp"
printf '\n ── 租户主账号认证 ──\n'
while [ -z "$DEPLOY_TENANT_EMAIL" ]; do
read -rp " 租户主账号邮箱: " DEPLOY_TENANT_EMAIL
printf '%s' "$DEPLOY_TENANT_EMAIL" | grep -qE '^[^@]+@[^@]+\.[^@]+$' || {
warn "邮箱格式不正确,请重新输入"
DEPLOY_TENANT_EMAIL=""
}
done
read -rsp " 租户主账号密码(仅用于认证,不存储明文): " _MIGRATE_PASSWORD; printf '\n'
[ -n "$_MIGRATE_PASSWORD" ] || fail "密码不能为空"
command -v mysql >/dev/null 2>&1 || \
fail "迁移模式需要 mysql 客户端: apt install -y mysql-client"
printf ' 正在连接生产 MySQL 进行身份认证 ...\n'
_SRC_HASH=$(MYSQL_PWD="$SRC_PASSWORD" mysql \
-h "$SRC_HOST" -P "$SRC_PORT" -u "$SRC_USER" \
--connect-timeout=10 "$SRC_DB" \
-N -e "SELECT password FROM t_tenant WHERE email='${DEPLOY_TENANT_EMAIL}' AND type='MAIN' LIMIT 1" 2>/dev/null || true)
[ -n "$_SRC_HASH" ] || \
fail "生产 MySQL 中未找到租户 ${DEPLOY_TENANT_EMAIL},或无法连接到 ${SRC_HOST}:${SRC_PORT}"
_AUTH_OK=$(MIGRATE_PW="$_MIGRATE_PASSWORD" MIGRATE_HASH="$_SRC_HASH" python3 -c "
import bcrypt, os
pw = os.environ['MIGRATE_PW'].encode('utf-8')
h = os.environ['MIGRATE_HASH'].encode('utf-8')
print('ok' if bcrypt.checkpw(pw, h) else 'fail')
")
unset _MIGRATE_PASSWORD _SRC_HASH
[ "$_AUTH_OK" = "ok" ] || fail "密码认证失败,请确认主账号密码后重试"
ok "生产租户 ${DEPLOY_TENANT_EMAIL} 身份认证通过"
DEPLOY_TENANT_USERNAME="$DEPLOY_TENANT_EMAIL"
DEPLOY_TENANT_NICKNAME=""
_BOOTSTRAP_PASSWORD="change-me-on-first-login"
fi
# ---------------------------------------------------------------------------
# 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')"
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 — 主配置文件,包含所有 profile
cat > "$ROOT_DIR/.env" <<EOF
# =============================================================================
# 数字医信私有化部署主配置
# 部署时间: $(date '+%Y-%m-%d %H:%M:%S')
# 部署主机: ${DEPLOY_HOST}
# =============================================================================
PRIVATE_VERSION=2026.05.19-private.1
REGISTRY=${REGISTRY}
REGISTRY_USER=${REGISTRY_USER}
REGISTRY_PASSWORD=${REGISTRY_PASSWORD}
IMAGE_TAG=${IMAGE_TAG}
# 启用全量服务base + 基础设施 + im + push + update + license
COMPOSE_PROFILES=base,infra-mysql,infra-redis,im,push,update,license
# MySQLmanaged 模式,Docker 容器托管)
MYSQL_MODE=managed
MYSQL_HOST=mysql
MYSQL_PORT=3306
MYSQL_DATABASE=${MYSQL_DATABASE}
MYSQL_USERNAME=${MYSQL_USERNAME}
# Redismanaged 模式)
REDIS_MODE=managed
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DATABASE=0
# 访问域名IP 部署时直接使用 IP,无需配置域名
CONSOLE_DOMAIN=${CONSOLE_BASE}
OPS_DOMAIN=${CONSOLE_BASE}
DOCS_DOMAIN=${CONSOLE_BASE}/docs
FILE_DOMAIN=${CONSOLE_BASE}
IM_DOMAIN=${CONSOLE_BASE}
UPDATE_DOMAIN=${CONSOLE_BASE}
LICENSE_DOMAIN=${CONSOLE_BASE}
PUSH_DOMAIN=${CONSOLE_BASE}
# 功能开关(全量启用)
ENABLE_FILE=true
ENABLE_IM=true
ENABLE_PUSH=true
ENABLE_UPDATE=true
ENABLE_LICENSE=true
TENANT_BOOTSTRAP_EMAIL=${DEPLOY_TENANT_EMAIL}
TENANT_BOOTSTRAP_APP_KEY=ak_private_default
EOF
ok ".env 已写入"
# config/secrets.env — 敏感信息(权限 600
mkdir -p "$ROOT_DIR/config"
cat > "$ROOT_DIR/config/secrets.env" <<EOF
# 数字医信私有化部署 — 敏感凭据请妥善保管,chmod 600
MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
MYSQL_PASSWORD=${MYSQL_PASSWORD}
REDIS_PASSWORD=${REDIS_PASSWORD}
EOF
chmod 600 "$ROOT_DIR/config/secrets.env"
ok "config/secrets.env 已写入 (chmod 600)"
# config/xuqm.env — 业务服务容器内配置(全服务开启)
cat > "$ROOT_DIR/config/xuqm.env" <<EOF
# =============================================================================
# 数字医信私有化部署 — 业务配置
# =============================================================================
# 私有化部署模式(必须为 PRIVATE
DEPLOYMENT_MODE=PRIVATE
TENANT_REGISTER_ENABLED=false
TENANT_BOOTSTRAP_ENABLED=true
# 功能开关(全服务启用)
ENABLE_FILE=true
ENABLE_IM=true
ENABLE_PUSH=true
ENABLE_UPDATE=true
ENABLE_LICENSE=true
# 数据库(容器内使用 Docker 服务名)
MYSQL_HOST=mysql
MYSQL_PORT=3306
MYSQL_DATABASE=${MYSQL_DATABASE}
MYSQL_USERNAME=${MYSQL_USERNAME}
# Redis容器内使用 Docker 服务名)
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DATABASE=0
# 访问域名IP 部署时直接使用 IP
CONSOLE_DOMAIN=${CONSOLE_BASE}
OPS_DOMAIN=${CONSOLE_BASE}
DOCS_DOMAIN=${CONSOLE_BASE}/docs
FILE_DOMAIN=${CONSOLE_BASE}
IM_DOMAIN=${CONSOLE_BASE}
UPDATE_DOMAIN=${CONSOLE_BASE}
LICENSE_DOMAIN=${CONSOLE_BASE}
PUSH_DOMAIN=${CONSOLE_BASE}
# SDK 对外服务地址(客户端 SDK 使用)
SDK_FILE_SERVICE_URL=${CONSOLE_BASE}
SDK_IM_API_URL=${CONSOLE_BASE}
SDK_IM_WS_URL=ws://${DEPLOY_HOST}/ws/im
# 系统 IM 通信应用 key私有化服务间消息通知使用此 app_key 连接 IM 服务)
# 与公有化平台 xuqinmin12 租户下的平台系统应用 key 保持一致
SYSTEM_APP_KEY=ak_409e217e4aa14254ad73ad3c
EOF
ok "config/xuqm.env 已写入"
# config/tenant/bootstrap.env — 使用收集到的租户信息(迁移模式为占位符,后续由迁移步骤覆盖)
mkdir -p "$ROOT_DIR/config/tenant"
cat > "$ROOT_DIR/config/tenant/bootstrap.env" <<EOF
TENANT_BOOTSTRAP_EMAIL=${DEPLOY_TENANT_EMAIL}
TENANT_BOOTSTRAP_USERNAME=${DEPLOY_TENANT_USERNAME}
TENANT_BOOTSTRAP_PASSWORD=${_BOOTSTRAP_PASSWORD}
TENANT_BOOTSTRAP_APP_KEY=ak_private_default
EOF
chmod 600 "$ROOT_DIR/config/tenant/bootstrap.env"
ok "config/tenant/bootstrap.env 已写入 (chmod 600)"
# config/nginx/conf.d/xuqm.conf — 从仓库中的文件复制(不再内嵌 heredoc,避免两处维护
mkdir -p "$ROOT_DIR/config/nginx/conf.d"
NGINX_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/config/nginx/conf.d/xuqm.conf"
if [ ! -f "$NGINX_SRC" ]; then
fail "nginx 配置文件不存在: $NGINX_SRC(请确保在仓库根目录运行本脚本)"
fi
# (以下 heredoc 仅作本地备份,deploy 时以上面复制为准,此段不执行)
: <<'NGINX_CONF'
# =============================================================================
# XuqmGroup 私有化部署 — Nginx 路由配置
#
# 服务端口映射:
# tenant-service 9001 /api/*(核心 API、/actuator/*
# file-service 8086 /file/*(文件上传下载)
# im-service 8082 /api/im/*IM HTTP、/ws/im/*WebSocket
# update-service 8084 /api/v1/updates/*、/api/v1/rn/*
# push-service 8083 厂商回调(内部端口)
# license-service 8085 内部调用
# ops-web 80 /ops/*(运营后台)
# tenant-web 80 /*(控制台,兜底路由)
# =============================================================================
server {
listen 80;
server_name _;
# 强制 UTF-8 编码,防止中文乱码
charset utf-8;
# 最大上传文件大小(文件服务单独设置 500m
client_max_body_size 100m;
# ----------- 健康检查 -----------
location /health {
return 200 "ok\n";
add_header Content-Type text/plain;
}
# ----------- 版本管理服务update-service:8084-----------
# 注意:必须在通用 /api/ 之前声明,否则会被错误路由到 tenant-service
location /api/v1/updates/ {
proxy_pass http://update-service:8084/api/v1/updates/;
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;
}
# RN 热更新包下载和列表
location /api/v1/rn/ {
proxy_pass http://update-service:8084/api/v1/rn/;
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 120s;
}
# ----------- IM 服务im-service:8082-----------
# 注意:必须在通用 /api/ 之前声明
location /api/im/ {
proxy_pass http://im-service:8082/api/im/;
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;
}
# IM WebSocket 长连接(客户端消息收发)
location /ws/im/ {
proxy_pass http://im-service:8082/ws/im/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s; # WebSocket 保持 1 小时
}
# ----------- License 服务license-service:8085-----------
# 注意:必须在通用 /api/ 之前声明
location /api/license/ {
proxy_pass http://license-service:8085/api/license/;
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;
}
# ----------- 核心 APItenant-service:9001-----------
# 注意tenant-service 运行在 9001 端口(不是 8080
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;
}
# Spring Boot Actuator 健康检查(内部监控用)
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;
proxy_send_timeout 300s;
}
# ----------- 文档站tenant-web 内置,VitePress base=/docs/-----------
location /docs/ {
proxy_pass http://tenant-web:80/docs/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# ----------- 运营后台ops-web:80-----------
location /ops {
proxy_pass http://ops-web:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# ----------- 控制台前端tenant-web:80-----------
# 兜底路由,必须放最后
location / {
proxy_pass http://tenant-web:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
NGINX_CONF
# 实际使用仓库中的文件(已跳过上方 heredoc
cp "$NGINX_SRC" "$ROOT_DIR/config/nginx/conf.d/xuqm.conf"
ok "config/nginx/conf.d/xuqm.conf 已写入(来自 $NGINX_SRC"
# config/vendors/push.env — 推送服务厂商凭据(初始为关闭状态,按需开启)
mkdir -p "$ROOT_DIR/config/vendors"
if [ ! -f "$ROOT_DIR/config/vendors/push.env" ]; then
cat > "$ROOT_DIR/config/vendors/push.env" <<'PUSH_ENV'
# 推送厂商凭据 — 按需填写并将对应 ENABLED 改为 true
HUAWEI_PUSH_ENABLED=false
MI_PUSH_ENABLED=false
OPPO_PUSH_ENABLED=false
VIVO_PUSH_ENABLED=false
HONOR_PUSH_ENABLED=false
APNS_PUSH_ENABLED=false
FCM_PUSH_ENABLED=false
PUSH_ENV
fi
# config/vendors/store-submit.env — 应用市场发布凭据(初始为关闭状态)
if [ ! -f "$ROOT_DIR/config/vendors/store-submit.env" ]; then
cat > "$ROOT_DIR/config/vendors/store-submit.env" <<'STORE_ENV'
# 应用市场自动发布凭据 — 按需填写并将对应 ENABLED 改为 true
HUAWEI_STORE_ENABLED=false
MI_STORE_ENABLED=false
OPPO_STORE_ENABLED=false
VIVO_STORE_ENABLED=false
HONOR_STORE_ENABLED=false
STORE_ENV
fi
# config/mail/smtp.env — 邮件服务配置(按需填写)
mkdir -p "$ROOT_DIR/config/mail"
if [ ! -f "$ROOT_DIR/config/mail/smtp.env" ]; then
cat > "$ROOT_DIR/config/mail/smtp.env" <<'SMTP_ENV'
# 邮件服务配置(按需填写,不填则邮件功能不可用)
SMTP_HOST=
SMTP_PORT=465
SMTP_USERNAME=
SMTP_TLS=true
SMTP_SSL=true
SMTP_ENV
fi
# SDK JSON 和文档站运行时配置
mkdir -p "$ROOT_DIR/config/sdk" "$ROOT_DIR/config/docs" "$ROOT_DIR/.deploy-state"
cat > "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" <<EOF
{
"schemaVersion": 1,
"configVersion": "2026.05.19-private.1",
"deployment": "PRIVATE",
"appKey": "ak_private_default",
"controlBaseUrl": "${CONSOLE_BASE}",
"fileBaseUrl": "${CONSOLE_BASE}",
"imApiBaseUrl": "${CONSOLE_BASE}",
"imWsUrl": "ws://${DEPLOY_HOST}",
"features": {
"file": true,
"im": true,
"push": true,
"update": true,
"license": true
},
"connectTimeoutMs": 10000,
"readTimeoutMs": 30000,
"logLevel": "WARN"
}
EOF
cat > "$ROOT_DIR/config/docs/docs-runtime.json" <<EOF
{
"deployment": "PRIVATE",
"privateVersion": "2026.05.19-private.1",
"domains": {
"console": "${CONSOLE_BASE}",
"file": "${CONSOLE_BASE}",
"im": "${CONSOLE_BASE}",
"update": "${CONSOLE_BASE}"
},
"features": { "file": true, "im": true, "push": true, "update": true, "license": true }
}
EOF
# 创建所有数据目录
mkdir -p \
"$ROOT_DIR/data/uploads" \
"$ROOT_DIR/data/mysql" \
"$ROOT_DIR/data/redis" \
"$ROOT_DIR/data/update" \
"$ROOT_DIR/data/backups" \
"$ROOT_DIR/logs"
ok "SDK JSON、文档站配置和目录结构就绪"
# 将 secrets 导出到 shell 环境,使 docker compose 变量替换可读取密码
set -a
# shellcheck disable=SC1090
. "$ROOT_DIR/config/secrets.env"
set +a
ok "secrets.env 已加载到 shell 环境"
# ---------------------------------------------------------------------------
# Step 3 — 登录镜像仓库
# ---------------------------------------------------------------------------
step "登录 ACR 镜像仓库"
printf '%s' "$REGISTRY_PASSWORD" | \
docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/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
# 等待 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 + im + push + update + license"
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 \
--profile im --profile push --profile update --profile license \
up -d
# 等待 tenant-service 健康(最多 120 秒)
printf ' 等待 tenant-service 启动'
for i in $(seq 1 40); do
code="$(curl -skL --noproxy '*' -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 5b — Schema 扩展 + 系统应用创建在迁移之前,Hibernate 已建表后执行)
# ---------------------------------------------------------------------------
step "Schema 扩展 + 系统 IM 应用初始化"
MYSQL_CTR_TMP="$(docker ps -qf name=mysql | head -1)"
# 检查并添加列(兼容 MySQL 8.x,不依赖 ADD COLUMN IF NOT EXISTS
for _col in is_default deletable; do
_exists="$(docker exec "$MYSQL_CTR_TMP" mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \
-N -e "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='t_app' AND COLUMN_NAME='${_col}'" 2>/dev/null || echo 0)"
if [ "${_exists:-0}" -eq 0 ]; then
_dv="$( [ "$_col" = "is_default" ] && echo 0 || echo 1 )"
docker exec "$MYSQL_CTR_TMP" mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \
-e "ALTER TABLE t_app ADD COLUMN ${_col} BIT(1) NOT NULL DEFAULT ${_dv}" 2>/dev/null \
&& ok "${_col} 已添加" || warn "${_col} 添加失败,继续"
else
ok "${_col} 已存在,跳过"
fi
done
# 触发器:先删再建,使用 --delimiter='$$'MySQL 批量模式不支持 DELIMITER 语句)
docker exec "$MYSQL_CTR_TMP" mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \
-e "DROP TRIGGER IF EXISTS prevent_default_app_delete" 2>/dev/null
_TRIG_FILE="$(mktemp /tmp/xuqm-trig-XXXXXX.sql)"
cat > "$_TRIG_FILE" << 'TRIG_SQL'
CREATE TRIGGER prevent_default_app_delete
BEFORE DELETE ON t_app
FOR EACH ROW
BEGIN
IF OLD.is_default = 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Cannot delete default application';
END IF;
END
TRIG_SQL
if docker exec -i "$MYSQL_CTR_TMP" mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \
--delimiter='$$' < "$_TRIG_FILE" 2>/dev/null; then
ok "删除保护触发器已创建"
else
warn "触发器创建遇到警告,继续"
fi
rm -f "$_TRIG_FILE"
# 创建系统 IM 应用bootstrap 阶段,迁移会重建;此处保证无迁移时也可用)
_SYS_APP_KEY="ak_409e217e4aa14254ad73ad3c"
_SYS_EXIST="$(docker exec "$MYSQL_CTR_TMP" \
mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \
-N -e "SELECT COUNT(*) FROM t_app WHERE app_key='${_SYS_APP_KEY}'" 2>/dev/null || echo 0)"
if [ "${_SYS_EXIST:-0}" -eq 0 ]; then
_SYS_SQL="$(mktemp /tmp/xuqm-sysapp-XXXXXX.sql)"
cat > "$_SYS_SQL" << 'SYSAPP_SQL'
INSERT IGNORE INTO t_app (id, app_key, app_secret, created_at, description, name, package_name, tenant_id)
SELECT UUID(), 'ak_409e217e4aa14254ad73ad3c',
CONCAT('as_sys_', LEFT(MD5(UUID()), 16)),
NOW(),
'系统内置应用 — 私有化服务间 IM 通信专用,is_default=1,不可删除',
'平台系统应用', 'com.xuqmgroup.platform',
id
FROM t_tenant ORDER BY created_at LIMIT 1;
INSERT INTO t_feature_service (id, app_key, created_at, enabled, platform, secret_key, service_type)
SELECT UUID(), 'ak_409e217e4aa14254ad73ad3c', NOW(), 1, 'ANDROID', CONCAT('sk_sys_a_', LEFT(MD5(UUID()), 8)), 'IM'
WHERE NOT EXISTS (SELECT 1 FROM t_feature_service WHERE app_key='ak_409e217e4aa14254ad73ad3c' AND platform='ANDROID' AND service_type='IM');
INSERT INTO t_feature_service (id, app_key, created_at, enabled, platform, secret_key, service_type)
SELECT UUID(), 'ak_409e217e4aa14254ad73ad3c', NOW(), 1, 'IOS', CONCAT('sk_sys_i_', LEFT(MD5(UUID()), 8)), 'IM'
WHERE NOT EXISTS (SELECT 1 FROM t_feature_service WHERE app_key='ak_409e217e4aa14254ad73ad3c' AND platform='IOS' AND service_type='IM');
INSERT INTO t_feature_service (id, app_key, created_at, enabled, platform, secret_key, service_type)
SELECT UUID(), 'ak_409e217e4aa14254ad73ad3c', NOW(), 1, 'HARMONY', CONCAT('sk_sys_h_', LEFT(MD5(UUID()), 8)), 'IM'
WHERE NOT EXISTS (SELECT 1 FROM t_feature_service WHERE app_key='ak_409e217e4aa14254ad73ad3c' AND platform='HARMONY' AND service_type='IM');
UPDATE t_app SET is_default=1, deletable=0 WHERE app_key='ak_409e217e4aa14254ad73ad3c';
SYSAPP_SQL
docker exec -i "$MYSQL_CTR_TMP" \
mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \
--default-character-set=utf8mb4 < "$_SYS_SQL" 2>/dev/null || warn "系统应用创建遇到警告,继续"
rm -f "$_SYS_SQL"
ok "系统应用 ${_SYS_APP_KEY} 已创建IM 三端启用,is_default=1"
else
ok "系统应用 ${_SYS_APP_KEY} 已存在,跳过创建"
fi
# ---------------------------------------------------------------------------
# Step 6 — 租户初始化(新建 / 迁移)
# ---------------------------------------------------------------------------
step "租户初始化(模式: ${DEPLOY_MODE}"
MYSQL_CTR="$(docker ps -qf name=mysql | head -1)"
if [ "$DEPLOY_MODE" = "new" ]; then
# ── 新建租户bootstrap 已在容器启动时执行,验证租户记录即可 ──
_NEW_TENANT_CNT="$(docker exec "$MYSQL_CTR" \
mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \
-N -e "SELECT COUNT(*) FROM t_tenant WHERE email='${DEPLOY_TENANT_EMAIL}'" 2>/dev/null || echo 0)"
if [ "${_NEW_TENANT_CNT:-0}" -ge 1 ]; then
ok "租户 ${DEPLOY_TENANT_EMAIL} 已由 bootstrap 创建"
else
warn "租户记录暂未就绪tenant-service 可能仍在初始化),继续部署"
fi
else
# ── 迁移租户:检查是否有现有数据,必要时警告确认后执行迁移 ──
_EXIST_CNT="$(docker exec "$MYSQL_CTR" \
mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \
-N -e "SELECT COUNT(*) FROM t_tenant" 2>/dev/null || echo 0)"
if [ "${_EXIST_CNT:-0}" -ge 1 ]; then
printf '\n \033[1;31m⚠ 警告:数据库中已存在租户数据!\033[0m\n'
printf ' 迁移操作将 \033[1;31m删除所有现有租户信息\033[0m账号、应用、功能配置,\n'
printf ' 并替换为生产环境租户 \033[1m%s\033[0m 的数据。\n\n' "$DEPLOY_TENANT_EMAIL"
read -rp " 确认删除并继续迁移?请输入 yes: " _confirm
[ "$_confirm" = "yes" ] || fail "操作已取消"
fi
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 "$DEPLOY_TENANT_EMAIL"
# 迁移完成后更新 bootstrap.env,防止重启时重新创建占位租户
cat > "$ROOT_DIR/config/tenant/bootstrap.env" <<EOF
TENANT_BOOTSTRAP_EMAIL=${DEPLOY_TENANT_EMAIL}
TENANT_BOOTSTRAP_USERNAME=${DEPLOY_TENANT_EMAIL}
TENANT_BOOTSTRAP_PASSWORD=already-migrated-do-not-use
TENANT_BOOTSTRAP_APP_KEY=ak_private_default
EOF
chmod 600 "$ROOT_DIR/config/tenant/bootstrap.env"
ok "bootstrap.env 已更新(迁移后)"
fi
# ---------------------------------------------------------------------------
# Step 7 — 一键验证(调用 verify.sh
# ---------------------------------------------------------------------------
step "一键验证所有服务"
# 将 DEPLOY_HOST 注入到 verify.sh 中使用
export DEPLOY_HOST="${DEPLOY_HOST}"
if bash "$ROOT_DIR/scripts/verify.sh"; then
ok "全量验证通过"
else
printf '\n\033[33m 部分验证项未通过,请查看上方输出。\033[0m\n'
printf ' 可重新运行DEPLOY_HOST=%s bash %s/scripts/verify.sh\n' "$DEPLOY_HOST" "$ROOT_DIR"
fi
# ---------------------------------------------------------------------------
# 最终汇总
# ---------------------------------------------------------------------------
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 '\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/docs/\n' "${CONSOLE_BASE}"
printf '\n \033[1m租户信息\033[0m\n'
printf ' 邮箱: %s\n' "${DEPLOY_TENANT_EMAIL}"
printf ' 用户名: %s\n' "${DEPLOY_TENANT_USERNAME}"
if [ "$DEPLOY_MODE" = "new" ]; then
printf ' 密码: 部署时设置的密码\n'
else
printf ' 密码: 同生产平台密码(原样迁移,未重置)\n'
fi
printf '\n \033[1m初始化方式\033[0m%s\n' "${DEPLOY_MODE}"
printf '\n \033[1m服务状态\033[0m\n'
printf ' 控制台 %s\n' "${CONSOLE_BASE}"
printf ' IM 服务 %s/api/im/\n' "${CONSOLE_BASE}"
printf ' 版本管理 %s/api/v1/updates/\n' "${CONSOLE_BASE}"
printf ' 文件服务 %s/file/\n' "${CONSOLE_BASE}"
printf ' License 服务 已启动(内部调用)\n'
printf ' 推送服务 已启动(厂商凭据见 config/vendors/push.env\n'
printf '\n \033[1m部署目录\033[0m %s\n' "$ROOT_DIR"
printf ' \033[1m验证结果\033[0m %s/.deploy-state/last-verify.json\n' "$ROOT_DIR"
printf ' \033[1m审计日志\033[0m %s/logs/audit.log\n' "$ROOT_DIR"
printf '\n\033[1;32m 部署成功!\033[0m\n\n'