刘光辉
9 小时以前 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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# JNPF 全栈部署自举校验(Backlog C1)
#   make up / make up-no-pull 的强制前置(SKIP_PREFLIGHT=1 逃生),也可独立运行。
#   目标:把"空库 PG 却 healthy、引擎/业务全线连不上"的静默失败拦在
#   compose up 之前,缺项直接给出修复指引(migration/install.sh、INSTALL.md)。
#
# 校验分三层:
#   硬前置(失败立即退出)  :docker CLI / daemon 可达 / compose v2 插件 / 编排文件存在
#   聚合检查(跑完统一汇总):.env 必填键(从 compose 的 ${VAR:?} 动态提取)、
#     客户坐标(CUSTOMER_DB_HOST/PORT 必填 + 实连探测 + 与 localdb profile 配套)、
#     postgres_pgdata 卷、PG 容器可起且接受连接、三库表数 ≥ 基准、
#     种子数据(jnpf_init 有 admin)、共享配置文件就位(补丁 #8)、业务角色齐
#   警告(不拦截):Docker 可用内存 <12GB(L024)、宿主 hosts 缺 cx-infra、
#
# 两种库形态,检查项按 CUSTOMER_DB_HOST 自动分流(2026-08-07):
#   栈内 PG(=cx-postgres,交付/正式):全套跑,含卷/容器/三库/种子/角色
#   远端客户库(=CVM 上的客户实例,开发机/测试环境):3-5 与角色检查跳过——那边的
#     卷、schema、种子、角色由该 PG 实例自身的运维负责(docs/db-connection-pool-guide.md),
#     本机既起不了那个容器也 docker exec 不进去;本机侧只验坐标可达与共享配置文件
#
# 用法:
#   ./docker/preflight.sh          # 仓库根执行(离线交付包内同路径,包根执行)
#
# 参数(环境变量,默认=现场标准值;改名可做隔离测试,不动真库):
#   CONTAINER     PG 容器名                默认 cx-postgres
#   VOLUME_NAME   PG external 数据卷名     默认 postgres_pgdata
#   ENV_FILE      凭据文件                 默认 .env
#   COMPOSE_FILE_PATH  编排文件            默认 docker-compose.yml
#   DB_INIT / DB_FLOW / DB_XXLJOB  三库名
#   PG_WAIT_SECS  等 PG 接受连接的上限秒数 默认 180(含大卷 crash recovery 窗口)
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
trap 'exit 130' INT
trap 'exit 143' TERM
 
CONTAINER="${CONTAINER:-cx-postgres}"
VOLUME_NAME="${VOLUME_NAME:-postgres_pgdata}"
ENV_FILE="${ENV_FILE:-.env}"
COMPOSE_FILE_PATH="${COMPOSE_FILE_PATH:-docker-compose.yml}"
DB_INIT="${DB_INIT:-jnpf_init}"
DB_FLOW="${DB_FLOW:-jnpf_flow}"
DB_XXLJOB="${DB_XXLJOB:-jnpf_xxljob}"
PG_WAIT_SECS="${PG_WAIT_SECS:-180}"
 
# 三库表数基准(下限,只允许 ≥,业务扩表不误报;install.sh 用精确匹配核对,本处刻意保守)
# 注:73→74 只为 20260724 新增 audit_events +1;与 install.sh 现行「官方293+自建84=377」的
# 精确基准存在既有 10 张漂移(早于本次改动,未在本次范围内一并追平,避免误改无关基准)
BASE_INIT=367   # 官方 293 + 自建 74
BASE_FLOW=70
BASE_XXLJOB=11
 
log()  { printf '\033[1;34m[preflight]\033[0m %s\n' "$*"; }
warnp(){ printf '\033[1;33m[preflight][warn]\033[0m %s\n' "$*"; }
die()  { printf '\033[1;31m[preflight][err]\033[0m %s\n' "$*" >&2; exit 1; }
 
# 修复指引的路径按运行位置自适应:离线交付包根有 migration/,仓库根在 docker/postgres/ 下
if [ -f migration/install.sh ]; then
  MIG_DIR="migration"
else
  MIG_DIR="docker/postgres/migration"
fi
 
# ── 聚合失败机制:单项失败不中断,跑完统一汇总(前置失败的项标记跳过) ──────
FAILS=()
WARNS=()
ok()   { printf '  \033[1;32m✓\033[0m %s\n' "$1"; }
bad()  { printf '  \033[1;31m✗\033[0m %s\n' "$1"; FAILS+=("$1"$'\n'"      修复:$2"); }
warn2(){ printf '  \033[1;33m!\033[0m %s\n' "$1"; WARNS+=("$1 —— $2"); }
# $2 可选:跳过原因后缀,默认「前置失败」(外部库模式等主动跳过场景传自己的原因)
skip() { printf '  \033[1;90m-\033[0m %s(%s,跳过)\n' "$1" "${2:-前置失败}"; }
 
psql_exec() { docker exec "$CONTAINER" psql -U postgres "$@"; }
get_env() { { grep -E "^$1=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2-; } || true; }
 
CDH=""; CDP=""
 
# ── 0. 硬前置:docker / compose ──────────────────────────────────────────────
log "===== 0/6 docker 环境 ====="
command -v docker >/dev/null 2>&1 \
  || die "未找到 docker 命令——先安装 Docker Engine(含 compose v2 插件)"
docker info >/dev/null 2>&1 \
  || die "docker daemon 不可达——确认 Docker/OrbStack 已启动,且当前用户有权限(docker info 报错内容为准)"
docker compose version >/dev/null 2>&1 \
  || die "docker compose v2 插件不可用——需要 Docker Compose V2(docker compose version 验证)"
ok "docker CLI / daemon / compose v2 就绪"
 
# 编排文件存在性提前到此处硬前置检查(原放在下方"1. .env 必填键",但下面若干
# 检查更早就用 awk 读取该文件——set -e 下 awk 遇文件不存在会以 awk 自身退出码裸退,绕过所有
# die() 提示;提到最前面确保任何读取 COMPOSE_FILE_PATH 的分支之前已保证文件存在)
[ -f "$COMPOSE_FILE_PATH" ] \
  || die "缺编排文件 ${COMPOSE_FILE_PATH}——请在仓库根/离线交付包根目录执行"
 
# 内存红线(L024:全栈 16 容器 <12GB 会 OOM 连锁:PG crash 循环 + gRPC 超时风暴)
# 四舍五入到 GiB:VM 名义 12GB 会被内核保留吃掉零头(12288MiB 实报 ~12.2GiB→整除变 11 误报)
MEM_TOTAL="$(docker info --format '{{.MemTotal}}' 2>/dev/null || echo 0)"
MEM_GIB=$(( (MEM_TOTAL + 512*1024*1024) / 1024 / 1024 / 1024 ))
if [ "$MEM_TOTAL" -gt 0 ] && [ "$MEM_GIB" -lt 12 ]; then
  warn2 "Docker 可用内存仅 ${MEM_GIB}GB(<12GB 红线)" "全栈需 ≥12GB:Linux 加物理内存/减容器;OrbStack 用 orb config set memory_mib 12288(L024)"
else
  ok "Docker 可用内存 ${MEM_GIB}GB(≥12GB)"
fi
 
# cx-infra 宿主解析(ApiDomain 双消费方约定;ApiDomain 已改现场地址的纯容器部署可忽略本项)
# Git Bash 下 /etc/hosts 是 Git 自带文件,Windows 真 hosts 在 System32/drivers/etc/hosts
HOSTS_FILE=/etc/hosts
case "$(uname -s)" in MINGW*|MSYS*) HOSTS_FILE="$(cygpath -u "$SYSTEMROOT")/System32/drivers/etc/hosts" ;; esac
if grep -q 'cx-infra' "$HOSTS_FILE" 2>/dev/null; then
  ok "宿主 hosts 已含 cx-infra"
else
  warn2 "宿主 hosts 缺 cx-infra" "补一行「127.0.0.1 cx-infra」(本机浏览器调试用;ApiDomain 已改为现场地址的纯容器部署可忽略)"
fi
 
# (RocketMQ 已于 2026-08-10 移除,原 mq-store 卷属主检查一并删除,见补丁 #10)
 
# ── 1. .env 必填键(从 compose 文件的 \${VAR:?} 动态提取,改 compose 自动跟进) ──
log "===== 1/6 .env 必填键 ====="
ENV_OK=1
# 编排文件存在性已在 0/6 硬前置检查(早于本节对该文件的 awk/grep 读取)
if [ ! -f "$ENV_FILE" ]; then
  ENV_OK=0
  bad "缺 ${ENV_FILE}(compose 凭据来源)" "cp .env.example .env 并按注释改值(交付环境必须全部改密,见 docs/release-checklist.md §0.7)"
else
  # 先滤注释行再提取(compose 头注释里有 ${VAR:?} 字样的说明文字,不滤会误提取)
  REQUIRED_KEYS="$(grep -vE '^[[:space:]]*#' "$COMPOSE_FILE_PATH" | grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*:\?' | sed -E 's/^\$\{//; s/:\?$//' | sort -u)"
  MISSING=()
  for key in $REQUIRED_KEYS; do
    # .env 里有非空值,或已在环境变量里(compose 两种来源都认)
    if grep -qE "^${key}=..*" "$ENV_FILE" || [ -n "${!key:-}" ]; then :; else MISSING+=("$key"); fi
  done
  if [ "${#MISSING[@]}" -eq 0 ]; then
    ok "必填键齐:$(echo "$REQUIRED_KEYS" | tr '\n' ' ')"
  else
    ENV_OK=0
    bad "${ENV_FILE} 缺必填键:${MISSING[*]}" "对照 .env.example 补齐(每个键的生成方法见文件内注释)"
  fi
fi
 
# ── 2. 客户坐标(docs/adr-multi-customer-db-isolation.md §3)────────────────
# 缺配会静默回退到默认 cx-postgres:5432——在开发机上就是连到本机沙盒而不是客户库,
# 数据写错地方且无任何提示。这两个变量同时决定业务库 jnpf_init 与引擎库 jnpf_flow
# (compose 的 FLOW_DB_URL 由它们推导),故两库不可能半切。
log "===== 2/6 客户坐标 ====="
if [ "$ENV_OK" = 0 ]; then
  skip "客户坐标"
else
  # `|| true` 不可省:set -euo pipefail 下 grep 无匹配返回 1,会经 pipefail 传给命令替换,
  # 令赋值失败并被 set -e 直接终止脚本——表现为打完节标题就静默退出(缺配恰恰是要报的情况)
  CDH="$(grep -E '^CUSTOMER_DB_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)"
  CDP="$(grep -E '^CUSTOMER_DB_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)"
  if [ -z "$CDH" ] || [ -z "$CDP" ]; then
    bad "${ENV_FILE} 缺 CUSTOMER_DB_HOST / CUSTOMER_DB_PORT" \
        "从 .env.example 抄这两行:同栈 PG 填 cx-postgres / 5432;客户库填 YOUR_DB_HOST / 154xx(客户1=15432、客户2=15433)"
  elif [ -n "$(printf '%s' "$CDP" | tr -d '0-9')" ]; then
    bad "CUSTOMER_DB_PORT 必须是整数,当前='${CDP}'" "改 ${ENV_FILE}"
  else
    echo "    生效坐标:${CDH}:${CDP}"
    # 栈内 PG 收在 localdb profile 内(默认不启动),开关必须与坐标配套,否则两种错配:
    #   坐标指栈内却没开 profile → PG 根本不起,全栈无库可连(硬失败)
    #   坐标指远端却开了 profile → 起一个没人连的 PG,还要求 external 卷存在(警告)
    PROFILES="$(printf '%s' "${COMPOSE_PROFILES:-$(get_env COMPOSE_PROFILES)}" | tr ' ' ',')"
    case ",${PROFILES}," in *,localdb,*) HAS_LOCALDB=1 ;; *) HAS_LOCALDB=0 ;; esac
    if [ "$CDH" = "cx-postgres" ] && [ "$HAS_LOCALDB" = 0 ]; then
      bad "CUSTOMER_DB_HOST=cx-postgres 但未启用 localdb profile(栈内 PG 不会启动,业务全线无库可连)" \
          "在 ${ENV_FILE} 加一行 COMPOSE_PROFILES=localdb(见 .env.example §1)"
    elif [ "$CDH" != "cx-postgres" ] && [ "$HAS_LOCALDB" = 1 ]; then
      warn2 "库在远端 ${CDH}:${CDP},却启用了 localdb profile" \
          "注释掉 ${ENV_FILE} 的 COMPOSE_PROFILES=localdb——否则会起一个没人连的 PG,且缺 external 卷 ${VOLUME_NAME} 时整栈起不来"
    fi
    if [ "$CDH" = "cx-postgres" ]; then
      # compose 内部服务名,宿主解析不了;起栈后由 PG healthcheck 与下面 3/6 保证
      ok "客户坐标 = 同栈 PG(${CDH}:${CDP}),跳过宿主实连探测"
    elif command -v nc >/dev/null 2>&1 && nc -z -w 5 "$CDH" "$CDP" >/dev/null 2>&1; then
      ok "客户坐标 ${CDH}:${CDP} 可达"
    elif ! command -v nc >/dev/null 2>&1 && \
         (exec 3<>"/dev/tcp/${CDH}/${CDP}") >/dev/null 2>&1; then
      # Git Bash 无 nc,退回 bash /dev/tcp(无超时控制,不可达时可能等到 TCP SYN 超时)
      ok "客户坐标 ${CDH}:${CDP} 可达(/dev/tcp 探测)"
    else
      bad "客户坐标 ${CDH}:${CDP} 连不上" \
          "确认端口填对(客户1=15432 / 客户2=15433)、云安全组已放行来源 IP、本机网络出站不受限"
    fi
  fi
 
  # Compose 生效的 Redis 坐标。JNPF_DOCKER_REDIS_* 允许宿主 JVM 用 127.0.0.1、
  # 容器改走 cx-infra(host-gateway),避免容器把 127.0.0.1 误当成宿主机。
  RDH_HOST="$(grep -E '^JNPF_REDIS_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)"
  RDP_HOST="$(grep -E '^JNPF_REDIS_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)"
  RDH="$(grep -E '^JNPF_DOCKER_REDIS_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)"
  RDP="$(grep -E '^JNPF_DOCKER_REDIS_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)"
  RDH="${RDH:-${RDH_HOST:-cx-redis}}"; RDP="${RDP:-${RDP_HOST:-6379}}"
  echo "    Compose Redis 坐标:${RDH}:${RDP}"
  if [ "$RDH" = "cx-redis" ]; then
    ok "Redis 坐标 = 同栈 redis(${RDH}:${RDP}),跳过宿主实连探测"
  else
    # 这两个名称只在容器内指向 host-gateway;预检运行在宿主,等价探测回环地址。
    REDIS_PROBE_HOST="$RDH"
    case "$RDH" in
      cx-infra|host.docker.internal) REDIS_PROBE_HOST=127.0.0.1 ;;
    esac
    if command -v nc >/dev/null 2>&1 && nc -z -w 5 "$REDIS_PROBE_HOST" "$RDP" >/dev/null 2>&1; then
      ok "Redis 坐标 ${RDH}:${RDP} 可达"
    elif ! command -v nc >/dev/null 2>&1 && \
         (exec 3<>"/dev/tcp/${REDIS_PROBE_HOST}/${RDP}") >/dev/null 2>&1; then
      ok "Redis 坐标 ${RDH}:${RDP} 可达(/dev/tcp 探测)"
    else
      bad "Redis 坐标 ${RDH}:${RDP} 连不上" \
          "宿主自装 Redis 请设 JNPF_DOCKER_REDIS_HOST=cx-infra,并确认 Redis 正监听对应端口;远端实例确认云安全组已放行来源 IP"
    fi
  fi
fi
 
# ── 外部库模式判定 ───────────────────────────────────────────────────────────
# 坐标指 CVM/远程实例时,本机 cx-postgres 不是数据面(测试环境已按 testenv.yml 用
# profiles 把它请出栈):3/6-6/6 的本地卷/容器/schema/种子检查全部跳过——否则 4/6
# 会把已停用的本地 PG 又拉起来。远程库连通性 2/6 已实连探测;schema/密码由 CVM 侧
# 管理 + 服务启动连库行为兜底。
EXTERNAL_DB=0
if [ "$ENV_OK" = 1 ] && [ -n "${CDH:-}" ] && [ "$CDH" != "cx-postgres" ]; then
  EXTERNAL_DB=1
fi
 
# ── 3. PG 数据卷 ─────────────────────────────────────────────────────────────
log "===== 3/6 数据卷 ${VOLUME_NAME} ====="
VOL_OK=1
if [ "$EXTERNAL_DB" = 1 ]; then
  VOL_OK=0
  skip "数据卷检查(本地 PG 非数据面)" "外部库模式 ${CDH}:${CDP}"
elif docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then
  ok "external 卷 ${VOLUME_NAME} 存在"
else
  VOL_OK=0
  bad "external 卷 ${VOLUME_NAME} 不存在(compose 声明 external,缺卷直接起不来)" \
      "docker volume create ${VOLUME_NAME},再灌数据:( cd ${MIG_DIR} && ./install.sh && ./create-app-roles.sh )(INSTALL.md §2 ⑤)"
fi
 
# ── 4. PG 容器可起且接受连接 ─────────────────────────────────────────────────
log "===== 4/6 PG 容器 ${CONTAINER} ====="
PG_OK=0
if [ "$EXTERNAL_DB" = 1 ]; then
  skip "PG 容器检查(不拉起本地 ${CONTAINER})" "外部库模式"
elif docker exec "$CONTAINER" true >/dev/null 2>&1; then
  PG_OK=1
elif [ "$VOL_OK" = 1 ] && [ "$ENV_OK" = 1 ]; then
  log "容器未在运行,尝试拉起:docker compose up -d cx-postgres"
  if docker compose up -d cx-postgres >/dev/null 2>&1; then
    PG_OK=1
  else
    bad "PG 容器拉起失败(docker compose up -d cx-postgres)" \
        "镜像缺失则先 make images(仓库)/ make load(离线包);再看 docker compose logs cx-postgres"
  fi
else
  skip "PG 容器拉起(.env 或数据卷未就绪)"
fi
 
if [ "$PG_OK" = 1 ]; then
  waited=0
  until docker exec "$CONTAINER" pg_isready -q -U postgres >/dev/null 2>&1; do
    if [ "$waited" -ge "$PG_WAIT_SECS" ]; then
      PG_OK=0
      bad "PG ${PG_WAIT_SECS}s 内未接受连接(大卷 crash recovery 可能更久)" \
          "docker compose logs cx-postgres 看恢复进度/报错;确认内存充足(L024)后可调大 PG_WAIT_SECS 重跑"
      break
    fi
    [ "$waited" = 0 ] && log "等待 PG 接受连接(上限 ${PG_WAIT_SECS}s,冷启动/crash recovery 属正常)…"
    sleep 3; waited=$((waited+3))
  done
  [ "$PG_OK" = 1 ] && ok "PG 接受连接(pg_isready)"
fi
 
# ── 5. 三库 schema:库存在 + 表数 ≥ 基准 ────────────────────────────────────
log "===== 5/6 三库 schema ====="
DBS_OK=1
if [ "$EXTERNAL_DB" = 1 ]; then
  DBS_OK=0
  skip "三库 schema 校验" "外部库模式"
elif [ "$PG_OK" = 1 ]; then
  check_db() {
    local db="$1" base="$2" n
    if ! psql_exec -tAc "SELECT 1 FROM pg_database WHERE datname='$db'" 2>/dev/null | grep -q 1; then
      DBS_OK=0
      bad "库 ${db} 不存在" "全新环境跑 ( cd ${MIG_DIR} && ./install.sh )(建四库+schema;INSTALL.md §2 ⑤)"
      return
    fi
    n="$(psql_exec -d "$db" -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'" 2>/dev/null || echo 0)"
    if [ "${n:-0}" -ge "$base" ]; then
      ok "库 ${db}:${n} 张表(基准 ≥${base})"
    else
      DBS_OK=0
      bad "库 ${db} 表数 ${n} < 基准 ${base}(schema 不完整/灌库中断)" \
          "重跑 ( cd ${MIG_DIR} && ./install.sh )——目标库非空会被守卫拦下,按提示 FORCE_RESEED=1(会清该库数据,先确认归属)"
    fi
  }
  check_db "$DB_INIT"   "$BASE_INIT"
  check_db "$DB_FLOW"   "$BASE_FLOW"
  check_db "$DB_XXLJOB" "$BASE_XXLJOB"
else
  DBS_OK=0
  skip "三库 schema 校验"
fi
 
# ── 6. 种子数据 + 共享配置文件 + 业务角色 ───────────────────────────────────
log "===== 6/6 种子数据、共享配置文件与业务角色 ====="
# 共享配置文件就位(补丁 #8:替代原配置中心种子检查)——与库无关,外部库模式也要查
for f in datasource resources system-config frame-config logger mq discovery; do
  if [ -f "config/shared/$f.yaml" ]; then
    ok "config/shared/${f}.yaml 就位"
  else
    bad "缺 config/shared/${f}.yaml(业务服务取不到该项配置,起来也全挂)" \
        "确认仓库内 config/shared/ 目录完整(补丁 #8 后配置随仓库分发,不再经配置中心下发)"
  fi
done
 
if [ "$PG_OK" = 1 ] && [ "$DBS_OK" = 1 ]; then
  n_admin="$(psql_exec -d "$DB_INIT" -tAc "SELECT count(*) FROM base_user WHERE f_account='admin'" 2>/dev/null || echo 0)"
  if [ "${n_admin:-0}" -ge 1 ]; then
    ok "种子用户 admin 在(${DB_INIT}.base_user)"
  else
    bad "${DB_INIT}.base_user 无 admin(种子数据缺失,登录/冒烟必失败)" \
        "全新环境:install.sh 的官方 init 含种子;割接环境:按 docs/archive/server-pg-cutover-checklist.md 跑 migrate-data.sh"
  fi
 
  # 业务角色(角色名以 .env 为准,缺省=交付标准值;密码是否匹配 preflight 不验——见 L030,
  # socket/127.0.0.1 是 trust 假验证,真实验证由服务启动后的连库行为兜底)
  APP_ROLE="$(get_env JNPF_APP_DB_USER)";   APP_ROLE="${APP_ROLE:-jnpf_app}"
  FLOW_ROLE="$(get_env JNPF_FLOW_DB_USER)"; FLOW_ROLE="${FLOW_ROLE:-jnpf_flow_app}"
  for role in "$APP_ROLE" "$FLOW_ROLE"; do
    if psql_exec -tAc "SELECT 1 FROM pg_roles WHERE rolname='$role'" 2>/dev/null | grep -q 1; then
      ok "业务角色 ${role} 在"
    else
      bad "业务角色 ${role} 不存在(对应服务连库必报 password authentication failed)" \
          "先定稿 ${ENV_FILE} 再跑 ( cd ${MIG_DIR} && ./create-app-roles.sh )(幂等,按 .env 重置密码;§0.11 铁律顺序)"
    fi
  done
else
  [ "$EXTERNAL_DB" = 1 ] && skip "种子数据与业务角色校验(由 CVM 侧管理)" "外部库模式" \
                         || skip "种子数据与业务角色校验"
fi
 
# ── 汇总 ─────────────────────────────────────────────────────────────────────
echo
if [ "${#WARNS[@]}" -gt 0 ]; then
  warnp "警告 ${#WARNS[@]} 项(不拦截):"
  for w in "${WARNS[@]}"; do printf '  · %s\n' "$w"; done
fi
if [ "${#FAILS[@]}" -eq 0 ]; then
  log "preflight 通过 ✓(可继续 docker compose up)"
else
  printf '\033[1;31m[preflight][err]\033[0m 未通过 %s 项:\n' "${#FAILS[@]}" >&2
  i=1
  for f in "${FAILS[@]}"; do printf '  %2d. %s\n' "$i" "$f" >&2; i=$((i+1)); done
  printf '\033[1;31m[preflight][err]\033[0m 修复后重跑 make preflight;确要跳过:SKIP_PREFLIGHT=1 make up\n' >&2
  exit 1
fi