刘光辉
8 小时以前 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
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
#!/usr/bin/env bash
set -euo pipefail
 
# ────────────────────────────────────────────────
# JNPF 微服务批量启动脚本
# 启动除 scheduletask 之外的所有服务(example 已于 2026-08-11 移除)
# 用法:
#   ./start-all.sh                            前台启动全部服务(实时显示就绪状态,Ctrl+C 统一停止)
#   ./start-all.sh --build                    先编译再前台启动
#   ./start-all.sh stop                       停止全部已启动的服务(用于其他终端)
#   ./start-all.sh restart                    停止 → 编译 → 前台启动
#   ./start-all.sh status                     查看各服务运行状态
#
#   ./start-all.sh jnpf-dms                   只启动 jnpf-dms
#   ./start-all.sh jnpf-lims                  只启动 jnpf-lims
#   ./start-all.sh restart jnpf-lims          只重启 jnpf-lims(仅编译该模块)
#   ./start-all.sh stop jnpf-lims             只停 jnpf-lims(不影响其他服务)
#   ./start-all.sh status jnpf-lims           只看 jnpf-lims 状态
#   ./start-all.sh restart jnpf-lims jnpf-platform  支持多服务
# ────────────────────────────────────────────────
 
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
PID_DIR="$BASE_DIR/.pids"
LOG_DIR="$BASE_DIR/log"
VERSION="6.1.0-RELEASE"
 
# 从仓库根 .env 加载全部 KEY=VALUE(宿主 java -jar 与 compose 共用同一份)。
# config/shared/*.yaml 的 ${VAR:默认} 靠进程环境解析;compose 侧由 docker 自动读 .env。
# 只按行取值不 source(.env 值可能含空格/特殊字符;与 create-app-roles.sh 同款)。
# .env 内同键以后出现的为准;启动前已在环境中的键不覆盖(允许 FOO=bar ./start-all.sh)。
if [ -f "$BASE_DIR/.env" ]; then
  # 快照启动前已存在的环境变量名(bash 3.2 无关联数组,用 |name| 串匹配)
  _preset="|"
  while IFS= read -r _pk; do
    [ -n "$_pk" ] && _preset="${_preset}${_pk}|"
  done <<EOF
$(env | cut -d= -f1)
EOF
  _loaded=0
  while IFS= read -r _line || [ -n "$_line" ]; do
    # 去 CR(Windows 换行)、跳过空行与注释
    _line="${_line%$'\r'}"
    case "$_line" in
      ''|\#*) continue ;;
    esac
    case "$_line" in
      *=*) ;;
      *) continue ;;
    esac
    _k="${_line%%=*}"
    _v="${_line#*=}"
    # 只接受合法标识符,避免把 "export FOO=bar" 这类行误当键
    case "$_k" in
      ''|*[!A-Za-z0-9_]*|[0-9]*) continue ;;
    esac
    case "$_preset" in
      *"|${_k}|"*) continue ;;
    esac
    # 未在启动前设定:直接 export;同键重复出现时后写覆盖前写
    if [ -z "${!_k+x}" ]; then
      _loaded=$((_loaded + 1))
    fi
    export "$_k=$_v"
  done < "$BASE_DIR/.env"
  echo "已从 .env 加载 ${_loaded} 个环境变量"
  unset _line _k _v _pk _preset _loaded
fi
 
# 宿主 Java 栈与容器内 OnlyOffice 互访:容器中的 127.0.0.1 指向容器自身,
# 因此 DS 回源和回调必须走 Docker 提供的宿主地址;显式环境变量仍可覆盖。
export ONLYOFFICE_FILE_FETCH_BASE_URL="${ONLYOFFICE_FILE_FETCH_BASE_URL:-http://host.docker.internal:30000}"
export ONLYOFFICE_CALLBACK_BASE_URL="${ONLYOFFICE_CALLBACK_BASE_URL:-http://host.docker.internal:30000}"
export ONLYOFFICE_DS_INTERNAL_URL="${ONLYOFFICE_DS_INTERNAL_URL:-http://localhost:20000}"
 
# 「本机」档坐标翻译:.env 写的是容器视角 cx-postgres:5432,宿主进程要走
# 宿主发布端口 127.0.0.1:5433(compose 只发布到 5433,避免与系统 PG 撞车)。
# 其余基础设施(cx-redis/cx-flow-engine)发布端口与容器内一致,
# 由 /etc/hosts 的 cx-* → 127.0.0.1 条目解决(双侧解析约定),不在此翻译。
# 指向 CVM 客户实例(IP 坐标)时本块不生效。
if [ "${CUSTOMER_DB_HOST:-}" = "cx-postgres" ]; then
  export CUSTOMER_DB_HOST=127.0.0.1
  export CUSTOMER_DB_PORT=5433
  echo "本机档:已翻译 PG 坐标 cx-postgres:5432 → 127.0.0.1:5433(宿主发布端口)"
fi
echo "客户坐标:${CUSTOMER_DB_HOST:-<未设,将回退 cx-postgres>}:${CUSTOMER_DB_PORT:-<未设,将回退 5432>}"
 
# JDK 9+ --add-opens 参数(与 Dockerfile 一致)
JVM_ADD_OPENS=(
  --add-opens=java.base/java.lang=ALL-UNNAMED
  --add-opens=java.base/java.lang.reflect=ALL-UNNAMED
  --add-opens=java.base/java.lang.invoke=ALL-UNNAMED
  --add-opens=java.base/java.math=ALL-UNNAMED
  --add-opens=java.base/sun.net.util=ALL-UNNAMED
  --add-opens=java.base/java.io=ALL-UNNAMED
  --add-opens=java.base/java.net=ALL-UNNAMED
  --add-opens=java.base/java.nio=ALL-UNNAMED
  --add-opens=java.base/java.security=ALL-UNNAMED
  --add-opens=java.base/java.text=ALL-UNNAMED
  --add-opens=java.base/java.time=ALL-UNNAMED
  --add-opens=java.base/java.util=ALL-UNNAMED
  --add-opens=java.base/jdk.internal.module=ALL-UNNAMED
  --add-opens=java.base/sun.security.util=ALL-UNNAMED
)
 
# 单个服务的最大 Java 堆内存。2026-08-11 平台聚合后常驻为 5 个 JVM
# (gateway / platform / biz-common / eln / lims),384m 的普通服务总堆上限约 1.5GB,
# platform 另用独立堆上限。
# 可按实际负载覆盖,例如:JNPF_SERVICE_MAX_HEAP=512m ./start-all.sh jnpf-lims
JNPF_SERVICE_MAX_HEAP="${JNPF_SERVICE_MAX_HEAP:-384m}"
# jnpf-platform 例外:九个模块的类元数据同处一个 JVM,384m 明显不够(实测堆 used 约 361MB
# 就是它的稳态下限)。单独给一档,与 compose 里 cx-platform 的 -Xmx1536m 对齐。
JNPF_PLATFORM_MAX_HEAP="${JNPF_PLATFORM_MAX_HEAP:-1536m}"
if [[ ! "$JNPF_SERVICE_MAX_HEAP" =~ ^[0-9]+[mMgG]$ ]]; then
  echo "JNPF_SERVICE_MAX_HEAP 必须是类似 384m 或 1g 的内存大小"
  exit 1
fi
if [[ ! "$JNPF_PLATFORM_MAX_HEAP" =~ ^[0-9]+[mMgG]$ ]]; then
  echo "JNPF_PLATFORM_MAX_HEAP 必须是类似 1536m 或 2g 的内存大小"
  exit 1
fi
 
JVM_OPTS=(
  "${JVM_ADD_OPENS[@]}"
  "-Xmx${JNPF_SERVICE_MAX_HEAP}"
  -Dfile.encoding=utf8
  -Djava.security.egd=file:/dev/./urandom
)
 
# 服务列表: "名称  JAR相对路径  端口"
# gateway 没有 -server 子模块,单独处理
SERVICES=(
  "jnpf-gateway     jnpf-gateway/target/jnpf-gateway-${VERSION}.jar                             30000"
  # 2026-08-11 平台聚合:下面这一行取代了原来的 oauth/system/visualdev/flowable/file/
  # message/permission/visualdata/app 九行。服务名在 config/host-override/discovery.yaml
  # 里全部别名到 127.0.0.1:30002,Feign 与网关路由一行未改。
  # 回滚:mvn -P microservice 重新打出九个 jar,把下面注释的九行放回来 + 还原 discovery 表。
  "jnpf-platform    jnpf-platform/jnpf-platform-server/target/jnpf-platform-${VERSION}.jar       30002"
  # "jnpf-oauth       jnpf-oauth/jnpf-oauth-server/target/jnpf-oauth-${VERSION}.jar               30001"
  # "jnpf-system      jnpf-system/jnpf-system-server/target/jnpf-system-${VERSION}.jar             30002"
  # "jnpf-visualdev   jnpf-visualdev/jnpf-visualdev-server/target/jnpf-visualdev-${VERSION}.jar    30003"
  # "jnpf-flowable    jnpf-flowable/jnpf-flowable-server/target/jnpf-flowable-${VERSION}.jar       30004"
  # "jnpf-file        jnpf-file/jnpf-file-server/target/jnpf-file-${VERSION}.jar                   30005"
  # "jnpf-message     jnpf-message/jnpf-message-server/target/jnpf-message-${VERSION}.jar          30008"
  # "jnpf-permission  jnpf-permission/jnpf-permission-server/target/jnpf-permission-${VERSION}.jar 30010"
  # "jnpf-visualdata  jnpf-visualdata/jnpf-visualdata-server/target/jnpf-visualdata-${VERSION}.jar 30011"
  # "jnpf-app         jnpf-app/jnpf-app-server/target/jnpf-app-${VERSION}.jar                     30012"
  # 2026-08-11 新增:审计搬入 jnpf-biz-common 后,**宿主栈不起它就等于没有审计能力**——
  # host-override/discovery.yaml 把服务名 jnpf-audit 解析到 127.0.0.1:30015,
  # 没有进程监听时 lims 的 audit-sdk 投递会全部失败、落 spool 等重放。
  # (此前 audit/biz-common 都未登记本表,是 biz-common P1 的 YAGNI 决定;审计搬入后不再成立。)
  # 放在 jnpf-lims 之前:lims 是审计事件的产生方,被投递方先就绪更顺。
  "jnpf-biz-common  jnpf-biz-common/jnpf-biz-common-server/target/jnpf-biz-common-${VERSION}.jar 30015"
  "jnpf-eln         jnpf-eln/jnpf-eln-server/target/jnpf-eln-${VERSION}.jar                    30013"
  "jnpf-lims        jnpf-lims/jnpf-lims-server/target/jnpf-lims-${VERSION}.jar               30019"
  "jnpf-dms         jnpf-dms/jnpf-dms-server/target/jnpf-dms-${VERSION}.jar                   30016"
)
 
# 补丁 #8:宿主栈直连坐标(覆盖 config/shared 里的 compose 默认值)
export JNPF_LOG_PROVIDER_URL="${JNPF_LOG_PROVIDER_URL:-dubbo://127.0.0.1:20880}"
SERVICE_READY_TIMEOUT="${SERVICE_READY_TIMEOUT:-360}"
 
mkdir -p "$PID_DIR"
# ⚠️ 变量必须写成 ${VAR} 形式:本行紧跟中文全角括号,而 macOS 自带 bash 3.2 按字节解析,
# 会把多字节字符的首字节吞进变量名(报 `JNPF_SERVICE_MAX_HEAP?: unbound variable`),
# 配合脚本头部的 set -u 直接终止。裸 $VAR 只有在其后是 ASCII 分隔符时才安全。
echo "JVM 单服务最大堆内存:${JNPF_SERVICE_MAX_HEAP}(jnpf-platform 单独用 ${JNPF_PLATFORM_MAX_HEAP})"
 
# ── 颜色 ──
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
 
# 命令行传入的服务名过滤后的目标列表,与 SERVICES 同结构("name jar port" 字符串数组)。
# 默认未指定时由 resolve_target_services 填充为全部服务。
TARGET_SERVICES=()
# 仅记录当前脚本进程实际新启动的服务;跳过的既有服务不归本次 Ctrl+C 管理。
STARTED_SERVICES=()
STARTED_SERVICE_COUNT=0
 
# 根据传入的服务名列表过滤 SERVICES,结果写到 TARGET_SERVICES。
# 空入参 = 全部服务;不存在的服务名直接报错退出。
resolve_target_services() {
  if [[ $# -eq 0 ]]; then
    TARGET_SERVICES=("${SERVICES[@]}")
    return 0
  fi
  TARGET_SERVICES=()
  local requested=("$@")
  for req in "${requested[@]}"; do
    local matched=""
    for entry in "${SERVICES[@]}"; do
      # shellcheck disable=SC2086
      set -- $entry
      if [[ "$1" == "$req" ]]; then
        matched="$entry"
        break
      fi
    done
    if [[ -z "$matched" ]]; then
      echo -e "${RED}未知服务名: ${req}${NC}"
      echo "可用服务:$(printf '%s ' "${SERVICES[@]}" | awk '{for(i=1;i<=NF;i+=3)printf "%s ",$i; print ""}')"
      exit 1
    fi
    TARGET_SERVICES+=("$matched")
  done
}
 
# 判断 TARGET_SERVICES 是否包含某个服务名
target_contains() {
  local name=$1
  for entry in "${TARGET_SERVICES[@]}"; do
    # shellcheck disable=SC2086
    set -- $entry
    [[ "$1" == "$name" ]] && return 0
  done
  return 1
}
 
port_is_listening() {
  (echo > "/dev/tcp/127.0.0.1/$1") >/dev/null 2>&1
}
 
port_listener_pid() {
  if command -v lsof >/dev/null 2>&1; then
    lsof -nP -tiTCP:"$1" -sTCP:LISTEN 2>/dev/null | head -n 1 || true
  fi
}
 
wait_for_port() {
  local name=$1 port=$2 timeout=${3:-60}
  local elapsed=0
  local start_ts=$(date +%s)
  local pid_file="$PID_DIR/${name}.pid"
  while (( elapsed < timeout )); do
    if port_is_listening "$port"; then
      local cost=$(( $(date +%s) - start_ts ))
      echo -e "  ${GREEN}[就绪]${NC} $name (端口 $port, ${cost}s)"
      return 0
    fi
    if [[ -f "$pid_file" ]] && ! kill -0 "$(cat "$pid_file")" 2>/dev/null; then
      echo -e "  ${RED}[失败]${NC} $name (端口 $port) — 进程已退出,查看 $LOG_DIR/$name/startup.log"
      return 1
    fi
    sleep 2
    elapsed=$((elapsed + 2))
  done
  echo -e "  ${YELLOW}[警告]${NC} $name (端口 $port) 在 ${timeout}s 内未就绪"
  return 1
}
 
start_service() {
  local name=$1 jar=$2 port=$3
  local jar_path="$BASE_DIR/$jar"
  local pid_file="$PID_DIR/${name}.pid"
  local log_file="$LOG_DIR/${name}/startup.log"
 
  if [[ -f "$pid_file" ]]; then
    if kill -0 "$(cat "$pid_file")" 2>/dev/null; then
      echo -e "  ${YELLOW}[跳过]${NC} $name (端口 $port) — 已在运行 (PID $(cat "$pid_file"))"
      return 0
    fi
    rm -f "$pid_file"
  fi
 
  local listener_pid
  listener_pid=$(port_listener_pid "$port")
  if [[ -n "$listener_pid" ]] || port_is_listening "$port"; then
    echo -e "  ${RED}[失败]${NC} $name — 端口 $port 已被未登记进程占用${listener_pid:+ (PID $listener_pid)}"
    return 1
  fi
 
  if [[ ! -f "$jar_path" ]]; then
    echo -e "  ${RED}[失败]${NC} $name — JAR 不存在: $jar"
    return 1
  fi
 
  mkdir -p "$(dirname "$log_file")"
 
  # Dubbo LogProvider 的唯一 provider 固定端口;其余服务保持 -1 不绑。
  # 2026-08-11:该角色随 jnpf-system-dubboservice 一并并入 jnpf-platform。
  # 不绑 20880 → 每个请求的 RequestLogAspect 都调用失败。
  local dubbo_port="-1"
  [[ "$name" == "jnpf-platform" ]] && dubbo_port="20880"
 
  # jnpf-platform 用单独的堆上限(九模块同 JVM,见 JNPF_PLATFORM_MAX_HEAP 处注释)
  local -a jvm_opts=("${JVM_OPTS[@]}")
  if [[ "$name" == "jnpf-platform" ]]; then
    jvm_opts=("${JVM_ADD_OPENS[@]}" "-Xmx${JNPF_PLATFORM_MAX_HEAP}" -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom)
  fi
 
  # 前台模式:后台启动子进程但不脱离会话(不用 nohup),脚本 Ctrl+C 时一并退出
  DUBBO_PROTOCOL_PORT=$dubbo_port java "${jvm_opts[@]}" -jar "$jar_path" > "$log_file" 2>&1 &
  local pid=$!
  echo "$pid" > "$pid_file"
  STARTED_SERVICES+=("$name $jar $port")
  STARTED_SERVICE_COUNT=$((STARTED_SERVICE_COUNT + 1))
 
  echo -e "  ${GREEN}[启动]${NC} $name (端口 $port, PID $pid) — 日志: $log_file"
}
 
# 并行等待一批服务就绪,实时打印每个服务的就绪/失败/超时状态
# 参数: timeout(整体超时秒数) + 服务条目数组("name jar port")
wait_services_ready() {
  local timeout=$1; shift
  local -a names ports statuses
  local n=0
  for entry in "$@"; do
    # shellcheck disable=SC2086
    set -- $entry
    names[n]=$1
    ports[n]=$3
    statuses[n]="pending"
    n=$((n + 1))
  done
 
  local total=$n
  local start_ts=$(date +%s)
  local done_cnt=0
  local elapsed=0
 
  while (( elapsed < timeout )) && (( done_cnt < total )); do
    local i=0
    while (( i < total )); do
      if [[ "${statuses[i]}" == "pending" ]]; then
        local name=${names[i]} port=${ports[i]}
        local pid_file="$PID_DIR/${name}.pid"
        local pid=""
        [[ -f "$pid_file" ]] && pid=$(cat "$pid_file")
 
        if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
          statuses[i]="failed"
          done_cnt=$((done_cnt + 1))
          echo -e "  ${RED}[失败]${NC} $name (端口 $port) — 进程已退出,查看 $LOG_DIR/$name/startup.log"
        elif port_is_listening "$port"; then
          statuses[i]="ready"
          done_cnt=$((done_cnt + 1))
          local cost=$(( $(date +%s) - start_ts ))
          echo -e "  ${GREEN}[就绪]${NC} $name (端口 $port, PID $pid, ${cost}s) [${done_cnt}/${total}]"
        fi
      fi
      i=$((i + 1))
    done
 
    if (( done_cnt < total )); then
      sleep 2
      elapsed=$(( $(date +%s) - start_ts ))
    fi
  done
 
  local ready_cnt=0 failed_cnt=0 timeout_cnt=0
  local i=0
  while (( i < total )); do
    case "${statuses[i]}" in
      ready)   ready_cnt=$((ready_cnt + 1)) ;;
      failed)  failed_cnt=$((failed_cnt + 1)) ;;
      pending)
        timeout_cnt=$((timeout_cnt + 1))
        echo -e "  ${YELLOW}[超时]${NC} ${names[i]} (端口 ${ports[i]}) — ${timeout}s 内未监听端口"
        ;;
    esac
    i=$((i + 1))
  done
 
  echo ""
  echo -e "── 启动结果: ${GREEN}就绪 ${ready_cnt}${NC}  ${RED}失败 ${failed_cnt}${NC}  ${YELLOW}超时 ${timeout_cnt}${NC}  /  合计 ${total} ──"
 
  (( failed_cnt == 0 && timeout_cnt == 0 ))
}
 
# 前台等待:脚本保持不退出,收到 INT/TERM 时统一终止所有子进程
foreground_wait() {
  if (( STARTED_SERVICE_COUNT == 0 )); then
    echo ""
    echo -e "${YELLOW}本次没有新启动的服务,脚本结束。${NC}"
    return 0
  fi
 
  echo ""
  echo -e "${GREEN}所有服务以前台模式运行,按 Ctrl+C 统一停止。${NC}"
  echo ""
 
  trap 'foreground_shutdown' INT TERM
 
  # 仅监控本次启动的子进程;其他终端启动的服务保持不动
  while true; do
    local any_alive=false
    for entry in "${STARTED_SERVICES[@]}"; do
      # shellcheck disable=SC2086
      set -- $entry
      local pid_file="$PID_DIR/$1.pid"
      if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
        any_alive=true
        break
      fi
    done
    if [[ "$any_alive" != "true" ]]; then
      echo -e "${YELLOW}所有子进程均已退出,脚本结束。${NC}"
      break
    fi
    sleep 5
  done
}
 
foreground_shutdown() {
  echo ""
  echo "── 收到中断信号,停止本次启动的服务 ──"
  stop_started_services
  echo "========================================"
  exit 0
}
 
stop_started_services() {
  local entry
  if (( STARTED_SERVICE_COUNT > 0 )); then
    for entry in "${STARTED_SERVICES[@]}"; do
      # shellcheck disable=SC2086
      stop_service $entry
    done
  fi
  STARTED_SERVICES=()
  STARTED_SERVICE_COUNT=0
}
 
startup_failed() {
  echo ""
  echo -e "${RED}启动未完成,清理本次已启动的服务。${NC}"
  stop_started_services
  return 1
}
 
stop_service() {
  local name=$1 port=$3
  local pid_file="$PID_DIR/${name}.pid"
 
  if [[ ! -f "$pid_file" ]]; then
    local listener_pid
    listener_pid=$(port_listener_pid "$port")
    if [[ -n "$listener_pid" ]] || port_is_listening "$port"; then
      echo -e "  ${YELLOW}[跳过]${NC} $name — 端口 $port 被未登记进程占用${listener_pid:+ (PID $listener_pid)},未自动停止"
    else
      echo -e "  ${YELLOW}[跳过]${NC} $name — 无 PID 文件"
    fi
    return 0
  fi
 
  local pid
  pid=$(cat "$pid_file")
 
  if kill -0 "$pid" 2>/dev/null; then
    kill "$pid"
    echo -e "  ${GREEN}[停止]${NC} $name (端口 $port, PID $pid)"
  else
    echo -e "  ${YELLOW}[跳过]${NC} $name — 进程已不存在 (PID $pid)"
  fi
 
  rm -f "$pid_file"
}
 
show_status() {
  local name=$1 port=$3
  local pid_file="$PID_DIR/${name}.pid"
 
  if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
    if port_is_listening "$port"; then
      echo -e "  ${GREEN}[运行中]${NC} $name  端口=$port  PID=$(cat "$pid_file")"
    else
      echo -e "  ${YELLOW}[启动中]${NC} $name  端口=$port  PID=$(cat "$pid_file")"
    fi
  else
    if [[ -f "$pid_file" ]]; then rm -f "$pid_file"; fi
    local listener_pid
    listener_pid=$(port_listener_pid "$port")
    if [[ -n "$listener_pid" ]] || port_is_listening "$port"; then
      echo -e "  ${YELLOW}[未登记]${NC} $name  端口=$port${listener_pid:+  PID=$listener_pid}"
    else
      echo -e "  ${RED}[未运行]${NC} $name  端口=$port"
    fi
  fi
  return 0
}
 
show_help() {
  cat <<EOF
JNPF 微服务批量启动脚本
 
用法: $0 [命令] [选项] [服务名...]
 
命令:
  start          启动服务(默认,前台运行,Ctrl+C 统一停止)
  stop           停止服务(用于其他终端)
  restart        停止 → 编译 → 启动(前台)
  status         查看运行状态
  help           显示此帮助信息
 
选项:
  --build, -b    启动前先编译项目
 
服务名(可选,省略 = 全部):
  jnpf-gateway  jnpf-platform  jnpf-biz-common  jnpf-lims  jnpf-dms
 
  注:jnpf-biz-common(30015) 含审计能力(2026-08-11 由原 jnpf-audit:30014 搬入)。
      只起 jnpf-lims 而不起它,审计事件会投递失败并落 spool。
 
环境变量:
  仓库根 .env           启动时自动加载全部 KEY=VALUE(可被命令行同名变量覆盖)
  SERVICE_READY_TIMEOUT 等待业务服务全部就绪的最长秒数(默认 360)
  JNPF_SERVICE_MAX_HEAP 单服务最大堆(默认 384m)
  LOAD_GATE             分波启动负载闸门(默认 9)
 
示例:
  $0                                      启动全部(缺失的 JAR 自动编译)
  $0 --build                              编译后启动全部
  $0 restart                              全量重启(自动全量编译)
  $0 stop                                 停止全部
  $0 status                               查看全部状态
 
  $0 jnpf-dms                             只启动 dms
  $0 restart jnpf-dms                     只重启 dms(仅编译该模块)
  $0 jnpf-lims                            只启动 lims
  $0 restart jnpf-lims                    只重启 lims(仅编译该模块,快很多)
  $0 stop jnpf-lims                       只停 lims
  $0 status jnpf-lims                     只看 lims 状态
  $0 restart jnpf-lims jnpf-platform      多服务一起重启
EOF
}
 
BUILD=false
ACTION=""
TARGET_NAMES=()
 
while [[ $# -gt 0 ]]; do
  case "$1" in
    --build|-b)     BUILD=true; shift ;;
    --help|-h|help) show_help; exit 0 ;;
    start|stop|restart|status)
      if [[ -n "$ACTION" ]]; then TARGET_NAMES+=("$1"); else ACTION="$1"; fi
      shift ;;
    *)
      # 第一个非选项位置参数若不是 action 关键字,按服务名收集
      TARGET_NAMES+=("$1"); shift ;;
  esac
done
 
[[ -z "$ACTION" ]] && ACTION="start"
# bash 3.2(macOS 自带)+ set -u 下,空数组用 "${arr[@]}" 展开会报 unbound;
# 用 "${arr[@]+...}" 这种兼容写法在数组为空时整段不展开。
resolve_target_services ${TARGET_NAMES[@]+"${TARGET_NAMES[@]}"}
 
build_service_entries() {
  local modules=()
  local entry jar
  for entry in "$@"; do
    # shellcheck disable=SC2086
    set -- $entry
    jar=$2
    # 把 jnpf-dms/jnpf-dms-server/target/xxx.jar 反推出 Maven 模块坐标。
    modules+=("$(dirname "$(dirname "$jar")")")
  done
  local pl
  pl=$(IFS=,; echo "${modules[*]}")
  echo "── 编译项目(仅 ${pl})──"
  if ! mvn -f "$BASE_DIR/pom.xml" -pl "$pl" -am clean package -DskipTests -q; then
    echo -e "${RED}编译失败,中止。${NC}"
    exit 1
  fi
  echo -e "${GREEN}编译完成${NC}"
}
 
do_build() {
  # 全量启动 → 编译整个项目;指定子集 → 用 -pl/-am 只编译相关模块(快很多)
  if [[ ${#TARGET_SERVICES[@]} -eq ${#SERVICES[@]} ]]; then
    echo "── 编译项目(全量)──"
    if ! mvn -f "$BASE_DIR/pom.xml" clean package -DskipTests -q; then
      echo -e "${RED}编译失败,中止。${NC}"
      exit 1
    fi
    echo -e "${GREEN}编译完成${NC}"
  else
    build_service_entries "${TARGET_SERVICES[@]}"
  fi
}
 
# 普通 start 复用现有 JAR,仅为首次检出或新服务自动补编缺失产物。
ensure_target_jars() {
  local missing=()
  local entry jar name
  for entry in "${TARGET_SERVICES[@]}"; do
    # shellcheck disable=SC2086
    set -- $entry
    name=$1
    jar=$2
    if [[ ! -f "$BASE_DIR/$jar" ]]; then
      echo -e "  ${YELLOW}[缺 JAR]${NC} $name — 将自动编译"
      missing+=("$entry")
    fi
  done
  if [[ ${#missing[@]} -gt 0 ]]; then
    build_service_entries "${missing[@]}"
  fi
}
 
# ── 分波启动(2026-08-06,对齐 docker/up-staged.sh)──
# 全量启动包含 5 个 JVM,其中 4 个业务 JVM 同启会瞬间打满 CPU
# (Druid 建连超时风险;容器栈同款问题的宿主版)。
# 按依赖重要度分波、波间过负载闸门;指定服务子集(≤4 个)时保持直启不分波。
LOAD_GATE="${LOAD_GATE:-9}"
 
load_now() {
  if sysctl -n vm.loadavg >/dev/null 2>&1; then
    sysctl -n vm.loadavg | awk '{print int($2)}'
  else
    awk '{print int($1)}' /proc/loadavg
  fi
}
 
wait_load_gate() {
  local l
  while :; do
    l=$(load_now)
    (( l < LOAD_GATE )) && return 0
    echo "  负载 ${l} ≥ 闸门 ${LOAD_GATE},等 20s 再放下一波…(可 LOAD_GATE=N 调整)"
    sleep 20
  done
}
 
# 波次成员表:未列出的服务名自动归收尾波(新增服务不用改这里)
wave_of() {
  case "$1" in
    jnpf-platform)    echo 1 ;;
    jnpf-biz-common)  echo 2 ;;
    jnpf-lims|jnpf-dms) echo 3 ;;
    *)                echo 4 ;;
  esac
}
 
# 消费 do_start 的 biz_services(bash 动态作用域),逐波启动并等就绪
start_in_waves() {
  local w entry
  for w in 1 2 3 4; do
    local wave_entries=()
    for entry in "${biz_services[@]}"; do
      # shellcheck disable=SC2086
      set -- $entry
      [[ "$(wave_of "$1")" == "$w" ]] && wave_entries+=("$entry")
    done
    [[ ${#wave_entries[@]} -eq 0 ]] && continue
    echo ""
    echo "── 波次 ${w}/4: $(printf '%s ' "${wave_entries[@]}" | awk '{for(i=1;i<=NF;i+=3)printf "%s ",$i}')──"
    wait_load_gate
    for entry in "${wave_entries[@]}"; do
      # shellcheck disable=SC2086
      start_service $entry || return 1
    done
    wait_services_ready "$SERVICE_READY_TIMEOUT" "${wave_entries[@]}" || return 1
  done
}
 
do_start() {
  echo "========================================"
  echo " 启动 JNPF 微服务 (本次 ${#TARGET_SERVICES[@]} 个 / 共 ${#SERVICES[@]} 个)"
  echo "========================================"
 
  if [[ ! -f "$BASE_DIR/config/shared/datasource.yaml" ]]; then
    echo -e "${RED}缺 config/shared/datasource.yaml(补丁 #8 后配置在仓库内),中止启动。${NC}"
    exit 1
  fi
 
  # 阶段 1: 仅在目标包含网关时启动并等待
  local biz_services=()
  if target_contains "jnpf-gateway"; then
    echo "── 阶段 1: 启动网关 ──"
    for entry in "${TARGET_SERVICES[@]}"; do
      # shellcheck disable=SC2086
      set -- $entry
      if [[ "$1" == "jnpf-gateway" ]]; then
        # shellcheck disable=SC2086
        start_service $entry || {
          startup_failed
          return 1
        }
        wait_for_port "jnpf-gateway" 30000 "$SERVICE_READY_TIMEOUT" || {
          startup_failed
          return 1
        }
      else
        biz_services+=("$entry")
      fi
    done
  else
    biz_services=("${TARGET_SERVICES[@]}")
  fi
 
  if [[ ${#biz_services[@]} -gt 0 ]]; then
    if [[ ${#TARGET_SERVICES[@]} -lt ${#SERVICES[@]} && ${#biz_services[@]} -le 4 ]]; then
      # 小子集直启(保持既有行为,重启单服务时不受波次拖慢)
      echo ""
      echo "── 阶段 2: 启动业务服务 ──"
      for entry in "${biz_services[@]}"; do
        # shellcheck disable=SC2086
        start_service $entry || {
          startup_failed
          return 1
        }
      done
      echo ""
      echo "── 阶段 3: 等待业务服务就绪 (最多 ${SERVICE_READY_TIMEOUT}s) ──"
      wait_services_ready "$SERVICE_READY_TIMEOUT" "${biz_services[@]}" || {
        startup_failed
        return 1
      }
    else
      echo ""
      echo "── 阶段 2: 分波启动业务服务(负载闸门 ${LOAD_GATE})──"
      start_in_waves || {
        startup_failed
        return 1
      }
    fi
  fi
 
  echo "========================================"
  echo " 日志目录: $LOG_DIR/<服务名>/startup.log"
  echo " 跨终端停止: $0 stop    跨终端查看状态: $0 status"
  echo "========================================"
 
  foreground_wait
}
 
case "$ACTION" in
  restart)
    echo "========================================"
    echo " 重启 JNPF 微服务 (stop → build → start) (本次 ${#TARGET_SERVICES[@]} 个)"
    echo "========================================"
    for entry in "${TARGET_SERVICES[@]}"; do
      # shellcheck disable=SC2086
      stop_service $entry
    done
    do_build
    do_start
    ;;
  start)
    if [[ "$BUILD" == true ]]; then
      do_build
    else
      ensure_target_jars
    fi
    do_start
    ;;
  stop)
    echo "========================================"
    echo " 停止 JNPF 微服务 (本次 ${#TARGET_SERVICES[@]} 个)"
    echo "========================================"
    for entry in "${TARGET_SERVICES[@]}"; do
      # shellcheck disable=SC2086
      stop_service $entry
    done
    echo "========================================"
    ;;
  status)
    echo "========================================"
    echo " JNPF 微服务状态 (本次 ${#TARGET_SERVICES[@]} 个)"
    echo "========================================"
    for entry in "${TARGET_SERVICES[@]}"; do
      # shellcheck disable=SC2086
      show_status $entry
    done
    echo "========================================"
    ;;
  *)
    echo "未知命令: $ACTION"
    echo ""
    show_help
    exit 1
    ;;
esac