#!/usr/bin/env bash # deploy.sh — XuqmGroup 私有化一键部署脚本 # # 用途:在目标机器上完成以下全部步骤: # 0. 交互式选择租户初始化方式(新建 / 迁移)并收集必要信息 # 1. 预检(Docker、Compose、磁盘、端口) # 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. 租户初始化(新建:填写信息并验证;迁移:凭迁移密钥调用 API 导入) # 8. 运行一键验证脚本确认所有服务正常 # # 幂等性:可重复执行。已运行的容器不会被重建。 # 迁移模式:若检测到已有租户数据,会在执行前提示确认。 # # 前提: # - Docker 和 Docker Compose v2 已安装 # - python3 和 curl 已安装 # - 在 XuqmGroup-PrivateDeploy 仓库根目录下执行本脚本 # # 覆盖默认值(可通过环境变量传入): # DEPLOY_HOST 目标机器 IP / 主机名(默认 localhost) # 部署向导会提示配置域名与 HTTPS # REGISTRY_PASSWORD ACR 密码(默认 xuqinmin1022) # MYSQL_ROOT_PASSWORD、MYSQL_PASSWORD、REDIS_PASSWORD 同理 set -euo pipefail # --------------------------------------------------------------------------- # 常量 # --------------------------------------------------------------------------- # 镜像仓库 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:-localhost}" CONSOLE_BASE="http://${DEPLOY_HOST}" _WS_SCHEME="ws" # 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}" # 公有化平台地址(迁移时调用 export API) PUBLIC_PLATFORM_URL="https://dev.xuqinmin.com" # --------------------------------------------------------------------------- # 内部变量 # --------------------------------------------------------------------------- ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" STEP=0 TOTAL_STEPS=7 # --------------------------------------------------------------------------- # 工具函数 # --------------------------------------------------------------------------- log() { printf '\n\033[1;36m[%d/%d] %s\033[0m\n' "$STEP" "$TOTAL_STEPS" "$*"; } info() { printf ' \033[1;36m→\033[0m %s\n' "$*"; } 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 XuqmGroup 私有化一键部署脚本(全量部署)\033[0m\n' printf '\033[1;35m══════════════════════════════════════════════════\033[0m\n' printf ' 部署目录: %s\n' "$ROOT_DIR" 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" command -v curl >/dev/null 2>&1 || \ fail "curl 未安装: apt install -y curl" 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) 迁移租户 — 从公有平台导入现有租户数据(需迁移密钥)\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 接收明文密码并在存储时自行 bcrypt(bootstrap.env chmod 600) _BOOTSTRAP_PASSWORD="$_pw" unset _pw ok "租户信息已收集" else # ── 迁移模式:先收集 SRC 配置 + 认证,再继续后续步骤 ── # ── 迁移模式:获取迁移密钥 ── printf '\n\033[1;33m 请先访问租户平台安全中心生成迁移密钥:\033[0m\n' printf ' 地址: %s\n' "$PUBLIC_PLATFORM_URL" printf ' 路径: 安全中心 → 私有化部署迁移 → 生成迁移密钥\n' printf ' (密钥仅在生成时显示一次,请及时复制后回到此处)\n\n' _MIGRATE_KEY="" while [ -z "$_MIGRATE_KEY" ]; do read -rp " 请粘贴迁移密钥: " _MIGRATE_KEY [[ "$_MIGRATE_KEY" == pmk_* ]] || { warn "密钥格式不正确(应以 pmk_ 开头),请重新输入" _MIGRATE_KEY="" } done ok "迁移密钥已接收" DEPLOY_TENANT_EMAIL="migrate_placeholder@private.local" DEPLOY_TENANT_USERNAME="migrate_placeholder" DEPLOY_TENANT_NICKNAME="" _BOOTSTRAP_PASSWORD="already-migrated-do-not-use" fi # --------------------------------------------------------------------------- # 0c. 外部访问地址(用于 SDK 配置,填写用户自己 nginx 代理后的地址) # --------------------------------------------------------------------------- printf '\n\033[1;33m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m\n' printf '\033[1;33m 外部访问地址\033[0m\n' printf '\033[1;33m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m\n' printf ' 填写您的 nginx 对外暴露的地址,SDK 客户端将使用此地址。\n' printf ' 例:http://192.168.1.100 或 https://xuqm.example.com\n\n' _EXT_BASE="" while [ -z "$_EXT_BASE" ]; do read -rp " 外部访问地址: " _EXT_BASE if ! printf '%s' "$_EXT_BASE" | grep -qE '^https?://'; then warn "请以 http:// 或 https:// 开头" _EXT_BASE="" fi done CONSOLE_BASE="${_EXT_BASE%/}" if printf '%s' "$CONSOLE_BASE" | grep -q '^https://'; then _WS_SCHEME="wss" fi DEPLOY_HOST="$(printf '%s' "$CONSOLE_BASE" | sed 's|https\?://||')" ok "外部地址: ${CONSOLE_BASE}" # --------------------------------------------------------------------------- # 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" < "$ROOT_DIR/config/secrets.env" < "$ROOT_DIR/config/xuqm.env" < "$ROOT_DIR/config/tenant/bootstrap.env" < /dev/null << NGINX_CONF # ============================================================================= # XuqmGroup 私有化部署 — Nginx HTTPS 配置(由 deploy.sh 自动生成) # 域名: ${DEPLOY_DOMAIN} # ============================================================================= # HTTP → HTTPS 重定向 server { listen 80; server_name ${DEPLOY_DOMAIN}; return 301 https://\$host\$request_uri; } server { listen 443 ssl; server_name ${DEPLOY_DOMAIN}; ssl_certificate /etc/letsencrypt/live/${DEPLOY_DOMAIN}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/${DEPLOY_DOMAIN}/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; charset utf-8; client_max_body_size 100m; location /health { return 200 "ok\n"; add_header Content-Type text/plain; } 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; } 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; } 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; } 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; } 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; } location /api/file/ { proxy_pass http://file-service:8086/api/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; } 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; } 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; } location /docs/ { proxy_pass http://tenant-web:80/docs/; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; } 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; proxy_set_header Accept-Encoding ""; sub_filter 'wss://im.dev.xuqinmin.com/ws/im' 'wss://\$host/ws/im'; sub_filter 'https://dev.xuqinmin.com' 'https://\$host'; sub_filter_once off; sub_filter_types text/javascript application/javascript; } } NGINX_CONF fi # 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" < "$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 # 等待 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 健康(直接打 9001 端口,不经过 nginx) printf ' 等待 tenant-service 启动' for i in $(seq 1 40); do code="$(curl -skL --noproxy '*' -o /dev/null -w '%{http_code}' --max-time 4 \ "http://127.0.0.1:11224/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 # 为 bootstrap 创建的 app 补写 app_licenses(license-service 独立 DB 写入) _LIC_SQL_NEW="$(mktemp /tmp/xuqm-lic-new-XXXXXX.sql)" cat > "$_LIC_SQL_NEW" <<'LIC_SQL_NEW' INSERT INTO app_licenses (app_key, name, max_devices, registered_devices, expires_at, is_active, remark, created_at, updated_at) SELECT a.app_key, a.name, 1000, 0, NULL, 1, '私有化新建', NOW(), NOW() FROM t_app a WHERE a.app_key != 'ak_409e217e4aa14254ad73ad3c' ON DUPLICATE KEY UPDATE updated_at = updated_at; LIC_SQL_NEW docker exec -i "$MYSQL_CTR" \ mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \ --default-character-set=utf8mb4 < "$_LIC_SQL_NEW" 2>/dev/null \ && ok "app_licenses 记录已为所有应用创建" \ || warn "app_licenses 写入遇到警告,继续" rm -f "$_LIC_SQL_NEW" else # ── 迁移租户:调用公有平台 export API → 私有平台 import API ── # 检查是否有现有租户数据 _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\n' read -rp " 确认删除并继续迁移?请输入 yes: " _confirm [ "$_confirm" = "yes" ] || fail "操作已取消" fi printf ' 正在从公有平台导出租户数据 ...\n' _EXPORT_BODY=$(python3 -c "import json, sys; print(json.dumps({'migrationKey': sys.argv[1]}))" "$_MIGRATE_KEY") _EXPORT_RESP=$(curl -sf --max-time 30 -X POST \ -H "Content-Type: application/json" \ -d "$_EXPORT_BODY" \ "${PUBLIC_PLATFORM_URL}/api/migrate/export") \ || fail "导出失败:请确认迁移密钥有效且未过期(${PUBLIC_PLATFORM_URL})" _EXPORT_DATA=$(printf '%s' "$_EXPORT_RESP" | python3 -c " import json, sys resp = json.load(sys.stdin) code = resp.get('code', 0) if code != 200: sys.exit('导出失败: ' + resp.get('message', 'unknown error')) print(json.dumps(resp['data'])) ") || fail "解析导出数据失败" ok "租户数据已从公有平台导出" printf ' 正在导入租户数据到私有部署 ...\n' _IMPORT_RESP=$(printf '%s' "$_EXPORT_DATA" | curl -s --max-time 30 -X POST \ -H "Content-Type: application/json" \ --data-binary @- \ -w "\n__HTTP_STATUS__:%{http_code}" \ "http://127.0.0.1:11224/api/private/deployment/migrate/import") _IMPORT_STATUS=$(printf '%s' "$_IMPORT_RESP" | grep -o '__HTTP_STATUS__:[0-9]*' | cut -d: -f2) _IMPORT_BODY=$(printf '%s' "$_IMPORT_RESP" | sed 's/__HTTP_STATUS__:[0-9]*//') if [ "${_IMPORT_STATUS}" != "200" ]; then fail "导入失败(HTTP ${_IMPORT_STATUS}): $(printf '%s' "$_IMPORT_BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('message','unknown'))" 2>/dev/null || printf '%s' "$_IMPORT_BODY")" fi # 从导出数据中提取租户信息用于最终汇总 _TENANT_INFO=$(printf '%s' "$_EXPORT_DATA" | python3 -c " import json, sys d = json.load(sys.stdin) t = d.get('tenant', {}) print(t.get('email', ''), t.get('username', '')) ") DEPLOY_TENANT_EMAIL=$(printf '%s' "$_TENANT_INFO" | awk '{print $1}') DEPLOY_TENANT_USERNAME=$(printf '%s' "$_TENANT_INFO" | awk '{print $2}') ok "租户 ${DEPLOY_TENANT_EMAIL} 数据已成功导入" # 为迁移的所有 app 在 app_licenses 表创建 license 记录(license-service 独立 DB 写入) _LIC_SQL="$(mktemp /tmp/xuqm-lic-XXXXXX.sql)" cat > "$_LIC_SQL" <<'LIC_SQL' INSERT INTO app_licenses (app_key, name, max_devices, registered_devices, expires_at, is_active, remark, created_at, updated_at) SELECT a.app_key, a.name, 1000, 0, NULL, 1, '私有化迁移', NOW(), NOW() FROM t_app a WHERE a.app_key != 'ak_409e217e4aa14254ad73ad3c' ON DUPLICATE KEY UPDATE updated_at = updated_at; LIC_SQL docker exec -i "$MYSQL_CTR" \ mysql -u "$MYSQL_USERNAME" -p"${MYSQL_PASSWORD}" "$MYSQL_DATABASE" \ --default-character-set=utf8mb4 < "$_LIC_SQL" 2>/dev/null \ && ok "app_licenses 记录已为所有迁移应用创建" \ || warn "app_licenses 写入遇到警告,继续" rm -f "$_LIC_SQL" # 更新 bootstrap.env,防止重启时重新创建占位租户 cat > "$ROOT_DIR/config/tenant/bootstrap.env" <