刘光辉
15 小时以前 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
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# JNPF 数据迁移(停机窗口一次性):现场 MySQL → 中转 mirror → pgloader → PG
#
#   dump    :从现场 MySQL mysqldump(跳 base_sys_log 日志)到本地 dump/
#   mirror  :起临时 mysql:8.0 中转容器,灌入 dump(pgloader 的 qmynd 不支持 MySQL8 握手,故中转)
#   load    :pgloader 灌 jnpf_init/jnpf_flow + blade_visual_map 走 --tab→COPY
#             + 数据接口修正 + log_seq setval + 抽样校验
#   cleanup :删除临时 mirror 容器与 dump/
#   all     :dump → mirror → load(不含 cleanup)
#
# 用法:
#   ./migrate-data.sh all         # 现场停机窗口全流程
#   ./migrate-data.sh load        # 只跑 PG 侧(mirror 已就绪时,或隔离测试)
#   ./migrate-data.sh cleanup     # 迁移验收后清理临时资源
#
# 参数(环境变量):
#   源库   SRC_MYSQL_HOST/PORT/USER/PWD   **无软默认**:HOST 与 PWD 必须显式传入(dump 步校验)
#   中转   MIRROR(容器名) MIRROR_PWD        默认 cx-mysql-mirror / mirror_2026(临时容器,随 cleanup 销毁)
#   目标   CONTAINER PGUSER                 默认 cx-postgres / postgres
#         PG_PASSWORD                       默认读仓库根/交付包根 .env 的 JNPF_PG_PASSWORD(load 步校验;
#                                           pgloader 走 TCP scram 真验密码,值必须正确,L030)
#         PG_INIT_DB PG_FLOW_DB            默认 jnpf_init / jnpf_flow(隔离测试可改 *_t8)
#         PG_INT_PORT                       容器内 PG 端口,默认 5432
#   NET    docker 网络                      默认自动取 CONTAINER 所在网络
#   DUMP_DIR / PGLOADER_IMAGE / MYSQL_IMAGE
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
 
# ── 退役护栏(2026-07-25)─────────────────────────────────────────────────────
# 本脚本的前提「腾讯云 MySQL = 事实源」自 2026-07-20 起已消失:现事实源是腾讯云 CVM 共享 PG
# (YOUR_DB_HOST:15432,开发与测试共用一库),MySQL 数据停在 07-16,仅作冷备回退底牌保留。
# 本脚本会 truncate 重灌目标库,误跑 = 用废弃旧快照覆盖当前业务数据,是真实数据事故。
# 背景与当前拓扑:README-迁移操作手册.md 文首 / docs/db-connection-pool-guide.md
if [ "${I_KNOW_MIGRATION_IS_RETIRED:-}" != "1" ]; then
  printf '\033[1;31m[已退役·拒绝执行]\033[0m %s\n' "migrate-data.sh 自 2026-07-21 迁移完成后已退役。" >&2
  printf '  前提已不成立:腾讯云 MySQL 不再是事实源(现为 CVM 共享 PG,MySQL 停在 07-16)。\n' >&2
  printf '  误跑后果:用废弃快照 truncate 重灌目标库 —— 会抹掉当前业务数据。\n' >&2
  printf '  若确在全新环境做一次性历史迁移,显式声明后重试:\n' >&2
  printf '    I_KNOW_MIGRATION_IS_RETIRED=1 %s %s\n' "$0" "${*:-}" >&2
  exit 1
fi
 
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SQL_DIR="$SCRIPT_DIR/sql"
 
# 凭据不设软默认(codex review 2026-07-17);.env 从脚本目录向上探测(仓库根或交付包根)
env_file() {
  local d="$SCRIPT_DIR"
  while [ "$d" != "/" ]; do
    if [ -f "$d/.env" ]; then printf '%s\n' "$d/.env"; return 0; fi
    d="$(dirname "$d")"
  done
  return 1
}
envval() {
  local f
  f="$(env_file)" || return 0
  grep -E "^$1=" "$f" | tail -1 | cut -d= -f2- || true
}
 
SRC_MYSQL_HOST="${SRC_MYSQL_HOST:-}"
SRC_MYSQL_PORT="${SRC_MYSQL_PORT:-3306}"
SRC_MYSQL_USER="${SRC_MYSQL_USER:-root}"
SRC_MYSQL_PWD="${SRC_MYSQL_PWD:-}"
 
MIRROR="${MIRROR:-cx-mysql-mirror}"
MIRROR_USER="${MIRROR_USER:-root}"
MIRROR_PWD="${MIRROR_PWD:-mirror_2026}"
 
CONTAINER="${CONTAINER:-cx-postgres}"
PGUSER="${PGUSER:-postgres}"
PG_PASSWORD="${PG_PASSWORD:-$(envval JNPF_PG_PASSWORD)}"
PG_INIT_DB="${PG_INIT_DB:-jnpf_init}"
PG_FLOW_DB="${PG_FLOW_DB:-jnpf_flow}"
PG_INT_PORT="${PG_INT_PORT:-5432}"
 
DUMP_DIR="${DUMP_DIR:-$SCRIPT_DIR/dump}"
PGLOADER_IMAGE="${PGLOADER_IMAGE:-dimitri/pgloader:latest}"
MYSQL_IMAGE="${MYSQL_IMAGE:-mysql:8.0}"
STEP="${1:-all}"
 
log()  { printf '\033[1;34m[migrate]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[migrate][warn]\033[0m %s\n' "$*"; }
die()  { printf '\033[1;31m[migrate][err]\033[0m %s\n' "$*" >&2; exit 1; }
 
discover_net() {
  docker inspect "$CONTAINER" >/dev/null 2>&1 || die "目标容器 $CONTAINER 不在——先 docker compose up -d cx-postgres 再跑 install.sh"
  docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$CONTAINER" | awk '{print $1}'
}
NET="${NET:-$(discover_net)}"
 
mirror_sql() { docker exec -i -e MYSQL_PWD="$MIRROR_PWD" "$MIRROR" mysql --default-character-set=utf8mb4 -u"$MIRROR_USER" "$@"; }
pg_sql()     { docker exec -i "$CONTAINER" psql -U "$PGUSER" "$@"; }
mirror_count(){ mirror_sql -N -e "SELECT COUNT(*) FROM \`$2\`" "$1" 2>/dev/null; }
pg_count()   { pg_sql -tAc "SELECT count(*) FROM $2" -d "$1"; }
 
# ── dump:现场 MySQL → dump/ ─────────────────────────────────────────────────
do_dump() {
  [ -n "$SRC_MYSQL_HOST" ] || die "缺 SRC_MYSQL_HOST(源 MySQL 地址须显式传入,如 SRC_MYSQL_HOST=127.0.0.1)"
  [ -n "$SRC_MYSQL_PWD" ]  || die "缺 SRC_MYSQL_PWD(源 MySQL 密码须显式传入,不设软默认)"
  mkdir -p "$DUMP_DIR"
  local common="--single-transaction --hex-blob --set-gtid-purged=OFF --default-character-set=utf8mb4 --column-statistics=0"
  log "mysqldump jnpf_init(跳 base_sys_log)… 源 $SRC_MYSQL_HOST:$SRC_MYSQL_PORT"
  docker run --rm -e MYSQL_PWD="$SRC_MYSQL_PWD" -v "$DUMP_DIR":/dump "$MYSQL_IMAGE" \
    sh -c "mysqldump -h$SRC_MYSQL_HOST -P$SRC_MYSQL_PORT -u$SRC_MYSQL_USER $common --ignore-table=jnpf_init.base_sys_log jnpf_init > /dump/jnpf_init.sql"
  log "mysqldump jnpf_flow …"
  docker run --rm -e MYSQL_PWD="$SRC_MYSQL_PWD" -v "$DUMP_DIR":/dump "$MYSQL_IMAGE" \
    sh -c "mysqldump -h$SRC_MYSQL_HOST -P$SRC_MYSQL_PORT -u$SRC_MYSQL_USER $common jnpf_flow > /dump/jnpf_flow.sql"
  log "dump 完成:$(du -sh "$DUMP_DIR" | cut -f1) @ $DUMP_DIR"
}
 
# ── mirror:起临时 mysql8 中转 + 灌 dump ─────────────────────────────────────
do_mirror() {
  [ -f "$DUMP_DIR/jnpf_init.sql" ] || die "缺 $DUMP_DIR/jnpf_init.sql,先跑 ./migrate-data.sh dump"
  # 就绪探测走真实认证:mysqladmin ping 在"认证被拒"时也返回 0,会在镜像首次初始化的
  # 临时服务器窗口(root 密码尚未生效)假通过,随后灌入撞 Access denied(L038)
  wait_mirror_ready() {
    log "等待 mirror 就绪(真实认证探测)…"
    for i in $(seq 1 60); do
      docker exec -e MYSQL_PWD="$MIRROR_PWD" "$MIRROR" mysql -u"$MIRROR_USER" -e "SELECT 1" >/dev/null 2>&1 && return 0
      sleep 3
    done
    die "mirror 60×3s 未就绪(docker logs $MIRROR 查看初始化进度)"
  }
  if docker inspect "$MIRROR" >/dev/null 2>&1; then
    if [ "$(docker inspect -f '{{.State.Running}}' "$MIRROR")" = "true" ]; then
      warn "mirror 容器 $MIRROR 运行中,复用(如需重灌先 ./migrate-data.sh cleanup)"
    else
      log "mirror 容器 $MIRROR 存在但已停止,启动(L035:不再盲目复用停止态容器)…"
      docker start "$MIRROR" >/dev/null
      wait_mirror_ready
    fi
  else
    log "起临时 mirror(mysql_native_password + skip-log-bin + innodb_strict_mode=OFF)于网络 $NET"
    docker run -d --name "$MIRROR" --network "$NET" -e MYSQL_ROOT_PASSWORD="$MIRROR_PWD" "$MYSQL_IMAGE" \
      --default-authentication-plugin=mysql_native_password --skip-log-bin --innodb-strict-mode=OFF >/dev/null
    wait_mirror_ready
  fi
  log "建库并灌入 dump(大表较慢)…"
  mirror_sql -e "CREATE DATABASE IF NOT EXISTS jnpf_init DEFAULT CHARSET utf8mb4; CREATE DATABASE IF NOT EXISTS jnpf_flow DEFAULT CHARSET utf8mb4;"
  mirror_sql jnpf_init < "$DUMP_DIR/jnpf_init.sql"
  mirror_sql jnpf_flow < "$DUMP_DIR/jnpf_flow.sql"
  log "mirror 灌入完成:jnpf_init blade_visual_map=$(mirror_count jnpf_init blade_visual_map) 行"
}
 
# ── pgloader 模板渲染 ────────────────────────────────────────────────────────
render_pgloader() {
  local tpl="$1" out="$2" pgdb="$3"
  [ -f "$tpl" ] || die "缺模板 $tpl"
  sed -e "s#{{MYSQL_USER}}#$MIRROR_USER#g" \
      -e "s#{{MYSQL_PASSWORD}}#$MIRROR_PWD#g" \
      -e "s#{{MYSQL_HOST}}#$MIRROR#g" \
      -e "s#{{MYSQL_PORT}}#3306#g" \
      -e "s#{{PG_USER}}#$PGUSER#g" \
      -e "s#{{PG_PASSWORD}}#$PG_PASSWORD#g" \
      -e "s#{{PG_HOST}}#$CONTAINER#g" \
      -e "s#{{PG_PORT}}#$PG_INT_PORT#g" \
      -e "s#{{PG_DB}}#$pgdb#g" \
      "$tpl" > "$out"
}
run_pgloader() {
  local loadfile="$1"
  docker run --rm --network "$NET" -v "$(dirname "$loadfile")":/loads "$PGLOADER_IMAGE" \
    pgloader "/loads/$(basename "$loadfile")"
}
 
# ── blade_visual_map:mysqldump --tab(NULL=\N,天然兼容)→ PG COPY ───────────
load_blade_visual_map() {
  log "blade_visual_map:mysqldump --tab → PG COPY(pgloader 对该巨行表会堆爆,故走 COPY)"
  docker exec -e MYSQL_PWD="$MIRROR_PWD" "$MIRROR" sh -c \
    "rm -f /var/lib/mysql-files/blade_visual_map.* && mysqldump --tab=/var/lib/mysql-files --default-character-set=utf8mb4 -u$MIRROR_USER jnpf_init blade_visual_map"
  pg_sql -d "$PG_INIT_DB" -c "TRUNCATE blade_visual_map"
  docker exec "$MIRROR" cat /var/lib/mysql-files/blade_visual_map.txt \
    | pg_sql -d "$PG_INIT_DB" -c "COPY blade_visual_map FROM STDIN"
  docker exec "$MIRROR" rm -f /var/lib/mysql-files/blade_visual_map.*
  log "blade_visual_map → PG $PG_INIT_DB = $(pg_count "$PG_INIT_DB" blade_visual_map) 行"
}
 
# ── load:pgloader + blade + 业务数据回填 + 数据接口修正 + setval + 抽样 ──────
do_load() {
  docker inspect "$MIRROR" >/dev/null 2>&1 || die "mirror $MIRROR 不在,先跑 ./migrate-data.sh mirror"
  [ -n "$PG_PASSWORD" ] || die "缺 PG_PASSWORD(显式传入,或在仓库根/交付包根 .env 设 JNPF_PG_PASSWORD)"
  # 渲染目录放 DUMP_DIR 下而非 mktemp:Git Bash 的 /tmp 是 MSYS 虚拟路径,
  # docker -v 挂载时 daemon 只会建空目录(L036/L038 同族);DUMP_DIR 的盘符形态已被 dump 步实证可挂载。
  # 注意 .loads 内含渲染后的连接密码——函数返回即删,cleanup 步兜底
  local rdir; rdir="$DUMP_DIR/.loads"; rm -rf "$rdir"; mkdir -p "$rdir"; trap 'rm -rf "$rdir"' RETURN
 
  log "===== pgloader jnpf_init → PG ${PG_INIT_DB}(排除 base_sys_log/blade_visual_map)====="
  render_pgloader "$SQL_DIR/pgloader_jnpf_init.load.tpl" "$rdir/init.load" "$PG_INIT_DB"
  run_pgloader "$rdir/init.load"
 
  load_blade_visual_map
 
  log "===== pgloader jnpf_flow → PG ${PG_FLOW_DB}(大写 ACT/FLW 表)====="
  render_pgloader "$SQL_DIR/pgloader_jnpf_flow.load.tpl" "$rdir/flow.load" "$PG_FLOW_DB"
  run_pgloader "$rdir/flow.load"
 
  log "===== 水系统计划流程状态回填(历史数据导入后执行)====="
  pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_flow_biz_status_20260719.sql"
  pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_quyang_zhongzhi_shenqing_biz_status_20260723.sql"
  pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_quyang_zhongzhi_shenqing_qingyandan_link_20260723.sql"
  pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_plan_status_20260720.sql"
  pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/create_lims_sxt_quyang_jihua_renwu_20260724.sql"
  pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/alter_lims_sxt_quyang_jihua_renwu_jianyan_xiang_kuaizhao_20260724.sql"
 
  log "===== 数据接口修正(含 4 条 UPDATE + 残留检查,应 0 行)====="
  pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/data_interface_pg_fixes.sql"
 
  log "===== lims_biz_log.log_seq setval ====="
  pg_sql -d "$PG_INIT_DB" -tAc \
    "SELECT setval(pg_get_serial_sequence('lims_biz_log','log_seq'), COALESCE((SELECT MAX(log_seq) FROM lims_biz_log),1), true)"
 
  log "===== 抽样行数校验(mirror vs PG)====="
  local bad=0
  for t in base_user zhiliang_biaozhun_guanli lims_qingyandan lims_jianyan_xiang lims_biz_log blade_visual_map; do
    local m p; m="$(mirror_count jnpf_init "$t")"; p="$(pg_count "$PG_INIT_DB" "$t")"
    if [ "$m" = "$p" ]; then printf '   %-30s mirror=%-8s pg=%-8s ✓\n' "$t" "$m" "$p"
    else printf '   %-30s mirror=%-8s pg=%-8s ✗\n' "$t" "$m" "$p"; bad=$((bad+1)); fi
  done
  [ "$bad" = 0 ] && log "抽样一致 ✓" || warn "抽样有 $bad 处不一致,跑 sql/rowcount_check.sh 全量核对"
  log "数据迁移完成。全量逐表核对:sql/rowcount_check.sh(详见 README)。"
}
 
# ── cleanup:删临时 mirror + dump ────────────────────────────────────────────
do_cleanup() {
  if docker inspect "$MIRROR" >/dev/null 2>&1; then
    log "删除临时 mirror 容器 $MIRROR"; docker rm -f "$MIRROR" >/dev/null
  fi
  [ -d "$DUMP_DIR" ] && { log "删除 dump 目录 $DUMP_DIR"; rm -rf "$DUMP_DIR"; }
  log "cleanup 完成 ✓"
}
 
case "$STEP" in
  dump)    do_dump ;;
  mirror)  do_mirror ;;
  load)    do_load ;;
  cleanup) do_cleanup ;;
  all)     do_dump; do_mirror; do_load ;;
  *)       die "未知步骤 '$STEP'(可选 dump|mirror|load|cleanup|all)" ;;
esac
log "migrate-data.sh [$STEP] 完成 ✓"