feat: 数字医信一键部署脚本 + 文档整理

scripts/deploy-szyx.sh (新增):
  七步幂等脚本,覆盖从预检到验收全流程:
  1. Docker / Compose / 磁盘 / 端口预检
  2. 写入数字医信专属配置(.env / secrets.env / xuqm.env /
     nginx / sdk-json),所有值固化为默认值,支持环境变量覆盖
  3. 登录 ACR (crpi-n44qjpuucgjt8e8c.cn-beijing.personal.cr.aliyuncs.com)
  4. 启动 MySQL + Redis 并轮询就绪
  5. 启动 base profile 业务容器并等待 actuator/health
  6. 迁移数字医信生产租户(szyx@bjca.org.cn),已迁移时自动跳过
  7. 验收:health / PRIVATE 模式 / 两个 appKey SDK config / 注册阻断 / 前端

docs/configuration.md:
  补充 Spring Boot SPRING_DATASOURCE_* 覆盖说明(application.yml
  硬编码生产 DB URL 的关键陷阱),nginx 服务端口对照表,
  docs-site 镜像可选说明。

docs/runbook.md:
  新增租户迁移章节,含前提、命令、自动步骤、验证示例。

README.md:
  快速参考:migrate-tenant.sh 用法、三条部署注意事项。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
徐勤民 2026-05-19 08:01:19 +08:00
父节点 f6189a5283
当前提交 f0649e9305
共有 4 个文件被更改,包括 643 次插入0 次删除

查看文件

@ -64,6 +64,26 @@ MySQL、Redis 支持两种模式:
./scripts/disable-service.sh im ./scripts/disable-service.sh im
``` ```
## 租户迁移
将公有化平台的存量租户迁移到私有化部署(需源 MySQL 网络可达):
```bash
./scripts/migrate-tenant.sh \
--src-host <生产MySQL地址> \
--src-user <用户名> \
--src-password '<密码>' \
--tenant <租户邮箱或用户名>
```
`--dry-run` 只打印 SQL 不执行。详见 `docs/runbook.md`
## 注意事项
- `tenant-service` 运行在容器内 **9001** 端口,nginx 代理必须指向该端口,不是 8080。
- `application.yml` 中数据库 URL 硬编码了生产地址,私有化部署依赖 `docker-compose.yml` 中的 `SPRING_DATASOURCE_*` 覆盖,不能删除 `environment:` 节。
- `docs-site` 镜像可选,不存在时 nginx 和 healthcheck 可正常工作warn 级别)。
## 接手入口 ## 接手入口
- 实时部署进度:`.deploy-state/progress.md` - 实时部署进度:`.deploy-state/progress.md`

查看文件

@ -38,6 +38,38 @@ TENANT_REGISTER_ENABLED=false
TENANT_BOOTSTRAP_ENABLED=true TENANT_BOOTSTRAP_ENABLED=true
``` ```
## Spring Boot 数据库 URL 覆盖(重要)
`tenant-service``file-service``application.yml` 中数据库 URL 编译期写死了生产地址。
私有化部署**必须**通过 `docker-compose.yml``environment:` 节覆盖 Spring Boot 配置:
```yaml
environment:
SPRING_DATASOURCE_URL: "jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT:-3306}/${MYSQL_DATABASE:-xuqm_private}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true"
SPRING_DATASOURCE_USERNAME: "${MYSQL_USERNAME:-xuqm}"
SPRING_DATASOURCE_PASSWORD: "${MYSQL_PASSWORD}"
SPRING_DATA_REDIS_HOST: "${REDIS_HOST}"
SPRING_DATA_REDIS_PORT: "${REDIS_PORT:-6379}"
SPRING_DATA_REDIS_PASSWORD: "${REDIS_PASSWORD}"
SPRING_DATA_REDIS_DATABASE: "${REDIS_DATABASE:-0}"
```
这些 `${VAR}` 在启动时由 Docker Compose 从 `.env` 文件展开,优先级高于 `env_file:` 中的同名变量。
当前 `docker-compose.yml` 已包含上述覆盖,不需要手动修改。
## Nginx 服务端口
| 服务 | 容器内端口 | 说明 |
|------|-----------|------|
| tenant-service | **9001** | Spring Boot `server.port=9001`,nginx 必须代理到该端口 |
| file-service | **8086** | nginx 代理 `/file/` 路径时使用 |
| ops-web | 80 | nginx 代理 `/ops` 路径时使用 |
| tenant-web | 80 | nginx 代理 `/` 根路径时使用 |
## docs-site 镜像
`docs-site` 镜像在部分 ACR namespace 下不存在。`docker-compose.yml` 已将 nginx 对 `docs-site` 的依赖标记为 `required: false`,镜像缺失时 nginx 仍可正常启动,`healthcheck.sh` 将 docs-site 容器计为可选WARN 而非 FAIL
## `config/sdk/xuqm-private-sdk.json` ## `config/sdk/xuqm-private-sdk.json`
私有化 SDK 初始化配置,由 `scripts/render-config.sh` 生成。 私有化 SDK 初始化配置,由 `scripts/render-config.sh` 生成。

查看文件

@ -47,6 +47,54 @@
禁用服务不会删除数据,重新启用后继续使用原配置和数据目录。 禁用服务不会删除数据,重新启用后继续使用原配置和数据目录。
## 租户迁移(公有化 → 私有化)
将公有化平台的存量租户迁移到私有化部署。
### 前提条件
- 私有化基础服务已通过 `healthcheck.sh`
- 源 MySQL 可从部署机器网络连通(`mysql -h SRC_HOST ...` 成功)。
- 私有化部署为单租户模式:迁移会**清空**当前 bootstrap 租户后写入迁移租户。
### 执行迁移
```bash
# Dry-run 确认要迁移的数据
./scripts/migrate-tenant.sh \
--src-host <生产MySQL> \
--src-user <用户名> \
--src-password '<密码>' \
--src-db xuqm_tenant \
--tenant <租户邮箱或用户名> \
--dry-run
# 确认无误后正式执行
./scripts/migrate-tenant.sh \
--src-host <生产MySQL> \
--src-user <用户名> \
--src-password '<密码>' \
--src-db xuqm_tenant \
--tenant <租户邮箱或用户名>
```
迁移脚本会自动:
1. 从源库导出 `t_tenant`、`t_app`、`t_feature_service`(含厂商配置)。
2. 用显式列名 INSERT 规避生产与私有化 MySQL 的列序差异。
3. 清空私有化部署的 bootstrap 租户后写入迁移数据。
4. 重启 `tenant-service` 清空内存缓存。
5. 通过 `/api/sdk/config``/api/private/deployment/status` 验证结果。
### 验证
```bash
# SDK config 应返回 200
curl "http://DEPLOY_HOST/api/sdk/config?appKey=<app_key>&platform=ANDROID"
# 部署状态应为 PRIVATE,注册应为 false
curl "http://DEPLOY_HOST/api/private/deployment/status"
```
## 接手规则 ## 接手规则
任何 agent 开始执行前必须先查看: 任何 agent 开始执行前必须先查看:

543
scripts/deploy-szyx.sh 可执行文件
查看文件

@ -0,0 +1,543 @@
#!/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}"
# 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}"
# 数字医信登录密码(不在脚本里重置,使用生产密码原文迁移)
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" <<EOF
PRIVATE_VERSION=2026.05.18-private.1
REGISTRY=${REGISTRY}
REGISTRY_USER=${REGISTRY_USER}
REGISTRY_PASSWORD=${REGISTRY_PASSWORD}
IMAGE_TAG=${IMAGE_TAG}
COMPOSE_PROFILES=base,infra-mysql,infra-redis
MYSQL_MODE=managed
MYSQL_HOST=mysql
MYSQL_PORT=3306
MYSQL_DATABASE=${MYSQL_DATABASE}
MYSQL_USERNAME=${MYSQL_USERNAME}
REDIS_MODE=managed
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DATABASE=0
CONSOLE_DOMAIN=${CONSOLE_BASE}
OPS_DOMAIN=${CONSOLE_BASE}
DOCS_DOMAIN=${CONSOLE_BASE}
FILE_DOMAIN=${CONSOLE_BASE}
IM_DOMAIN=
UPDATE_DOMAIN=
LICENSE_DOMAIN=
PUSH_DOMAIN=
ENABLE_FILE=true
ENABLE_IM=false
ENABLE_PUSH=false
ENABLE_UPDATE=false
ENABLE_LICENSE=false
TENANT_BOOTSTRAP_EMAIL=${TENANT_EMAIL}
TENANT_BOOTSTRAP_APP_KEY=ak_private_default
EOF
ok ".env 已写入"
# config/secrets.env
mkdir -p "$ROOT_DIR/config"
cat > "$ROOT_DIR/config/secrets.env" <<EOF
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
DEPLOYMENT_MODE=PRIVATE
TENANT_REGISTER_ENABLED=false
TENANT_BOOTSTRAP_ENABLED=true
ENABLE_FILE=true
ENABLE_IM=false
ENABLE_PUSH=false
ENABLE_UPDATE=false
ENABLE_LICENSE=false
MYSQL_HOST=mysql
MYSQL_PORT=3306
MYSQL_DATABASE=${MYSQL_DATABASE}
MYSQL_USERNAME=${MYSQL_USERNAME}
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DATABASE=0
CONSOLE_DOMAIN=${CONSOLE_BASE}
OPS_DOMAIN=${CONSOLE_BASE}
DOCS_DOMAIN=${CONSOLE_BASE}
FILE_DOMAIN=${CONSOLE_BASE}
IM_DOMAIN=
UPDATE_DOMAIN=
LICENSE_DOMAIN=
PUSH_DOMAIN=
SDK_FILE_SERVICE_URL=${CONSOLE_BASE}
SDK_IM_API_URL=
SDK_IM_WS_URL=
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=${TENANT_EMAIL}
TENANT_BOOTSTRAP_USERNAME=${TENANT_USERNAME}
TENANT_BOOTSTRAP_PASSWORD=change-me-on-first-login
TENANT_BOOTSTRAP_APP_KEY=ak_private_default
EOF
ok "config/tenant/bootstrap.env 已写入"
# config/nginx/conf.d/xuqm.conf — 完整路由
mkdir -p "$ROOT_DIR/config/nginx/conf.d"
cat > "$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" <<EOF
{
"schemaVersion": 1,
"configVersion": "2026.05.18-private.1",
"deployment": "PRIVATE",
"appKey": "ak_private_default",
"controlBaseUrl": "${CONSOLE_BASE}",
"fileBaseUrl": "${CONSOLE_BASE}",
"imApiBaseUrl": "",
"imWsUrl": "",
"features": {
"file": true,
"im": false,
"push": false,
"update": false,
"license": false
},
"connectTimeoutMs": 10000,
"readTimeoutMs": 30000,
"logLevel": "WARN"
}
EOF
cat > "$ROOT_DIR/config/docs/docs-runtime.json" <<EOF
{
"deployment": "PRIVATE",
"privateVersion": "2026.05.18-private.1",
"domains": { "console": "${CONSOLE_BASE}", "file": "${CONSOLE_BASE}" },
"features": { "file": true, "im": false, "push": false, "update": false, "license": false }
}
EOF
mkdir -p "$ROOT_DIR/data/uploads" "$ROOT_DIR/data/mysql" "$ROOT_DIR/data/redis" \
"$ROOT_DIR/data/backups" "$ROOT_DIR/logs"
ok "SDK JSON 和目录结构就绪"
# ---------------------------------------------------------------------------
# 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 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'