#!/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
|