刘光辉
11 小时以前 34981c30a78e8bbd7791131059a9210f9928b62c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
#!/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