diff --git a/README.md b/README.md index 42dd02d..f907b86 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,26 @@ MySQL、Redis 支持两种模式: ./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` diff --git a/docs/configuration.md b/docs/configuration.md index ccf9500..d931569 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,6 +38,38 @@ TENANT_REGISTER_ENABLED=false 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` 私有化 SDK 初始化配置,由 `scripts/render-config.sh` 生成。 diff --git a/docs/runbook.md b/docs/runbook.md index 11ac897..2678c6a 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -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=&platform=ANDROID" + +# 部署状态应为 PRIVATE,注册应为 false +curl "http://DEPLOY_HOST/api/private/deployment/status" +``` + ## 接手规则 任何 agent 开始执行前必须先查看: diff --git a/scripts/deploy-szyx.sh b/scripts/deploy-szyx.sh new file mode 100755 index 0000000..b82531a --- /dev/null +++ b/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}" + +# 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'