#!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # JNPF 挪库发布:把共享库(开发+测试共用)整体搬到下一个环境(如正式环境) # # 为什么是"挪库"而不是"现场建 schema": # JNPF 是低代码平台,表单/菜单/字典/数据接口/流程定义全部存在数据库表里。 # 现场跑 install.sh 只能得到空结构,配置一个都不会有。所以上线 = 把整个库搬过去, # 配置和数据一次性带走(用户 2026-07-21 定的策略,见 docs/release-checklist.md §1)。 # # 两阶段分离,各自在不同机器上执行: # dump 在任何能连源库的机器上跑(拉出四库的 -Fc 备份) # restore 【必须在目标机本地跑】——建库要超级用户,而 postgres 按设计禁止远程登录 # verify 在目标机上跑(逐库表数/行数与 dump 记录的清单比对) # # 用法: # # ① 在能连源库的机器上(把 dump 目录整个拷到目标机) # ./promote-db.sh dump # # ② 在目标机上(先确保 PG 容器已起、且已跑过 create-app-roles.sh) # DST_CONTAINER=cx-postgres DUMP_DIR=/d/promote-20260725 ./promote-db.sh restore # DST_CONTAINER=cx-postgres DUMP_DIR=/d/promote-20260725 ./promote-db.sh verify # # 参数(环境变量): # SRC_HOST/SRC_PORT/SRC_USER/SRC_PASSWORD 源库(默认读 .env.tencent 的 CVM 共享库) # DST_CONTAINER 目标 PG 容器名(默认 cx-postgres) # DUMP_DIR dump 产物目录(默认 ./promote-dump-<日期>) # DATABASES 默认 "jnpf_init jnpf_flow jnpf_xxljob nacos_config" # ALLOW_NONEMPTY=1 允许目标库非空(默认拒绝,防误覆盖生产) # # ⚠️ 本脚本刻意【不做】的事(都是需要人判断、不该由脚本替你决定的): # - 不清洗测试业务数据:哪些请验单/流程实例算"测试垃圾"只有业务能定,见配套文档「洗数据」节 # - 不改 Nacos 配置里的环境相关值(ApiDomain/FrontDomain 等):restore 后按文档清单人工核对 # - 不建角色:restore 前必须先跑 create-app-roles.sh(用目标环境的密码),否则 owner 落不对 # 配套文档:docs/db-promote-guide.md(含不确定点与未来可能的变化) # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" # 默认只挪三个库。**刻意不含 jnpf_xxljob**:它的 11 张表 owner 是 postgres,而 CVM/生产的 # postgres 按设计禁止远程登录 → 远程 dump 不了;且它装的是 xxl-job 的任务注册与执行日志, # 正式环境本就该是全新的,不该把测试环境的任务日志搬过去。正式环境跑 install.sh 建空结构即可。 # (确要连它一起搬,只能在源机本地用超户 dump,见 docs/db-promote-guide.md「jnpf_xxljob 怎么办」) DATABASES="${DATABASES:-jnpf_init jnpf_flow nacos_config}" DUMP_DIR="${DUMP_DIR:-$PWD/promote-dump-$(date +%Y%m%d)}" DST_CONTAINER="${DST_CONTAINER:-cx-postgres}" # 三个业务角色:restore 前必须已存在,否则 dump 里的 OWNER TO 落不下去 REQUIRED_ROLES="${REQUIRED_ROLES:-jnpf_app jnpf_flow_app nacos_app}" # 每个库归属不同角色(三库三角色隔离),dump 必须用各自的 owner —— 用单一 app 角色会在 # 别的库上撞 "permission denied for table"(实测 2026-07-25)。格式:库=角色:密码键 db_role() { case "$1" in jnpf_init) echo "jnpf_app|JNPF_APP_DB_PASSWORD" ;; jnpf_flow) echo "jnpf_flow_app|JNPF_FLOW_DB_PASSWORD" ;; nacos_config) echo "nacos_app|JNPF_NACOS_DB_PASSWORD" ;; *) echo "" ;; esac } log() { printf '\033[1;34m[promote]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[promote][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[promote][err]\033[0m %s\n' "$*" >&2; exit 1; } step() { printf '\n\033[1;36m── %s ──\033[0m\n' "$*"; } # 源库连接:默认取 CVM 共享库(各角色密码在仓库根 .env.tencent,已 gitignore) SRC_HOST="${SRC_HOST:-YOUR_DB_HOST}" SRC_PORT="${SRC_PORT:-15432}" SRC_ENV_FILE="${SRC_ENV_FILE:-$REPO_ROOT/.env.tencent}" # 从 env 文件取指定键的密码;SRC_PASSWORD_<键> 形式的环境变量可覆盖(供非 CVM 源使用) src_password_for() { # $1=密码键名,如 JNPF_APP_DB_PASSWORD local key="$1" ov ov="$(eval "printf '%s' \"\${${key}:-}\"")" if [ -n "$ov" ]; then printf '%s' "$ov"; return 0; fi [ -f "$SRC_ENV_FILE" ] || return 1 grep -E "^${key}=" "$SRC_ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" } # pg_dump/pg_restore 来源:优先宿主自带(版本须 ≥ 服务端 18),否则用本地 PG 镜像跑 PG_IMAGE="${PG_IMAGE:-cx/postgres:18.4}" have_local_pgdump() { command -v pg_dump >/dev/null 2>&1; } usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 1; } # ─────────────────────────────────────────────────────────────── dump ──────── do_dump() { step "阶段 1/3:从源库导出($SRC_HOST:${SRC_PORT})" mkdir -p "$DUMP_DIR" local fail=0 for db in $DATABASES; do local map usr key pwd out map="$(db_role "$db")" if [ -z "$map" ]; then warn " ✗ ${db} 没有对应的业务角色(其表多半归 postgres,而超户禁远程)" warn " → 该库只能在源机本地用超户 dump,或确认它是否真需要挪;见 docs/db-promote-guide.md" fail=$((fail+1)); continue fi usr="${map%%|*}"; key="${map##*|}" pwd="$(src_password_for "$key" || true)" [ -n "$pwd" ] || { warn " ✗ ${db} 取不到密码(键 $key,查 $SRC_ENV_FILE 或设同名环境变量)"; fail=$((fail+1)); continue; } out="$DUMP_DIR/${db}.dump" log "pg_dump ${db}(角色 ${usr})…" if have_local_pgdump; then PGPASSWORD="$pwd" pg_dump -h "$SRC_HOST" -p "$SRC_PORT" -U "$usr" \ -Fc -d "$db" > "$out" 2>"$DUMP_DIR/${db}.dump.err" || { warn " ✗ ${db} 导出失败,见 ${db}.dump.err"; rm -f "$out"; fail=$((fail+1)); continue; } else docker run --rm -e PGPASSWORD="$pwd" "$PG_IMAGE" \ pg_dump -h "$SRC_HOST" -p "$SRC_PORT" -U "$usr" -Fc -d "$db" > "$out" 2>"$DUMP_DIR/${db}.dump.err" \ || { warn " ✗ ${db} 导出失败,见 ${db}.dump.err"; rm -f "$out"; fail=$((fail+1)); continue; } fi log " ✓ ${db} $(du -h "$out" | cut -f1)" done [ "$fail" = 0 ] || die "有 ${fail} 个库导出失败" step "记录源库清单(供 verify 阶段比对)" local manifest="$DUMP_DIR/MANIFEST.txt" { echo "# JNPF 挪库发布 dump 清单" echo "# 源: $SRC_HOST:$SRC_PORT 导出时间: $(date '+%Y-%m-%d %H:%M:%S')" for db in $DATABASES; do local n n=$(query_src "$db" "select count(*) from information_schema.tables where table_schema='public' and table_type='BASE TABLE'") echo "TABLES|$db|$n" # jnpf_init 逐表行数(业务主库,值得精确记录) if [ "$db" = "jnpf_init" ]; then query_src "$db" "select 'ROWS|$db|'||relname||'|'||(xpath('/row/c/text()', query_to_xml(format('select count(*) as c from public.%I', relname), false, true, '')))[1]::text from pg_stat_user_tables order by relname" fi done } > "$manifest" 2>/dev/null || warn "清单生成部分失败(不影响 dump 本身)" local nrows; nrows=$(grep -c '^ROWS' "$manifest" 2>/dev/null) || nrows=0 log "清单已写入 $(basename "$manifest")(含 ${nrows} 张表的精确行数)" step "dump 完成" log "产物目录:$DUMP_DIR" log "下一步:把整个目录拷到目标机,然后在【目标机上】执行 restore" warn "该目录含全部业务数据,传输与留存按敏感数据对待;导入完成后清理" } query_src() { # $1=库 $2=SQL —— 自动用该库对应的 owner 角色连接 local map usr key pwd map="$(db_role "$1")"; [ -n "$map" ] || return 1 usr="${map%%|*}"; key="${map##*|}" pwd="$(src_password_for "$key" || true)"; [ -n "$pwd" ] || return 1 if have_local_pgdump; then PGPASSWORD="$pwd" psql -h "$SRC_HOST" -p "$SRC_PORT" -U "$usr" -d "$1" -tAc "$2" 2>/dev/null else docker run --rm -e PGPASSWORD="$pwd" "$PG_IMAGE" \ psql -h "$SRC_HOST" -p "$SRC_PORT" -U "$usr" -d "$1" -tAc "$2" 2>/dev/null fi } dst_psql() { docker exec -i "$DST_CONTAINER" psql -U postgres -tAc "$1" 2>/dev/null; } # ───────────────────────────────────────────────────────────── restore ─────── do_restore() { step "阶段 2/3:灌入目标库(容器 ${DST_CONTAINER})" [ -d "$DUMP_DIR" ] || die "dump 目录不存在:${DUMP_DIR}(先在源侧跑 dump 并把目录拷过来)" docker inspect "$DST_CONTAINER" >/dev/null 2>&1 || die "目标容器 $DST_CONTAINER 不在(先 docker compose up -d cx-postgres)" step "前置检查" # ① 角色必须已存在——否则 dump 里的 OWNER TO 落不下去,将来 app 角色改不了表(L041) for r in $REQUIRED_ROLES; do if [ "$(dst_psql "select 1 from pg_roles where rolname='$r'")" = "1" ]; then log " ✓ 角色 $r 已存在" else die "角色 $r 不存在。restore 前必须先用【目标环境的密码】跑 create-app-roles.sh, 否则 dump 中的 OWNER TO 无处落地,事后 app 角色会改不动表(lessons L041)。" fi done # ② 目标库应为空——防止把已有生产数据覆盖掉 for db in $DATABASES; do if [ "$(dst_psql "select 1 from pg_database where datname='$db'")" = "1" ]; then local n n=$(docker exec -i "$DST_CONTAINER" psql -U postgres -d "$db" -tAc \ "select count(*) from information_schema.tables where table_schema='public'" 2>/dev/null || echo 0) if [ "${n:-0}" -gt 0 ]; then if [ "${ALLOW_NONEMPTY:-}" = "1" ]; then warn " ! $db 已有 $n 张表,ALLOW_NONEMPTY=1 → 将以 --clean --if-exists 覆盖" else die "$db 已存在且有 $n 张表。默认拒绝覆盖以防抹掉生产数据。 确认要覆盖请显式声明:ALLOW_NONEMPTY=1 $0 restore" fi fi fi done step "开始灌入" local fail=0 for db in $DATABASES; do local f="$DUMP_DIR/${db}.dump" [ -f "$f" ] || { warn " ✗ 缺 ${db}.dump,跳过"; fail=$((fail+1)); continue; } dst_psql "select 1 from pg_database where datname='$db'" >/dev/null 2>&1 if [ "$(dst_psql "select 1 from pg_database where datname='$db'")" != "1" ]; then log " 建库 $db" docker exec -i "$DST_CONTAINER" createdb -U postgres "$db" || { warn " ✗ 建库 $db 失败"; fail=$((fail+1)); continue; } fi log "pg_restore ${db} …" local opts="-U postgres -d $db --no-privileges --exit-on-error" [ "${ALLOW_NONEMPTY:-}" = "1" ] && opts="$opts --clean --if-exists" # shellcheck disable=SC2086 if docker exec -i "$DST_CONTAINER" pg_restore $opts < "$f" 2>"$DUMP_DIR/${db}.restore.err"; then log " ✓ ${db}" else warn " ✗ ${db} 灌入报错,见 ${db}.restore.err(前 3 行):" head -3 "$DUMP_DIR/${db}.restore.err" | sed 's/^/ /' fail=$((fail+1)) fi done [ "$fail" = 0 ] || die "有 ${fail} 个库灌入失败" step "restore 完成 —— 但发布还没结束" cat <<'NEXT' 必须人工接着做的三件事(脚本刻意不代劳,详见 docs/db-promote-guide.md): 1. 核对 Nacos 环境相关配置:ApiDomain / FrontDomain / 各服务地址 —— 这些值随环境变, 搬过来的是源环境的值,不改会导致文件预览 302 跳到错误域名(lessons L040) 2. 决定是否清洗测试业务数据:配置必须带走,但测试期的请验单/流程实例通常该清 3. 跑 verify 子命令做行数比对,再按 docs/release-checklist.md 过发布门禁 NEXT } # ────────────────────────────────────────────────────────────── verify ─────── do_verify() { step "阶段 3/3:校验(目标容器 $DST_CONTAINER vs dump 清单)" local manifest="$DUMP_DIR/MANIFEST.txt" [ -f "$manifest" ] || die "缺清单 ${manifest}(dump 阶段生成,需与 dump 目录一同拷来)" local bad=0 step "表数量比对" while IFS='|' read -r tag db n; do [ "$tag" = "TABLES" ] || continue local m m=$(docker exec -i "$DST_CONTAINER" psql -U postgres -d "$db" -tAc \ "select count(*) from information_schema.tables where table_schema='public' and table_type='BASE TABLE'" 2>/dev/null || echo "-") if [ "$m" = "$n" ]; then log " ✓ $db $m 张表"; else warn " ✗ $db 源 $n / 目标 $m"; bad=$((bad+1)); fi done < <(grep '^TABLES' "$manifest") step "jnpf_init 逐表行数比对" local diff=0 checked=0 while IFS='|' read -r tag db tbl n; do [ "$tag" = "ROWS" ] || continue checked=$((checked+1)) local m m=$(docker exec -i "$DST_CONTAINER" psql -U postgres -d "$db" -tAc "select count(*) from public.\"$tbl\"" 2>/dev/null || echo "-") if [ "$m" != "$n" ]; then printf ' ✗ %-46s 源=%-8s 目标=%s\n' "$tbl" "$n" "$m"; diff=$((diff+1)); fi done < <(grep '^ROWS' "$manifest") log "逐表比对:$checked 张表,$diff 张不一致" [ "$diff" = 0 ] || warn "不一致的表若全是日志类(base_sys_log 等),可能是 dump 后源库仍在写入所致,人工确认" [ "$bad" = 0 ] && [ "$diff" = 0 ] && { step "校验通过 ✓"; return 0; } step "校验未全通过 —— 逐条确认后再放行"; return 1 } case "${1:-}" in dump) do_dump ;; restore) do_restore ;; verify) do_verify ;; all) die "不提供 all:dump 与 restore 按设计在不同机器上执行(restore 需目标机本地超户),请分别执行" ;; *) usage ;; esac