刘光辉
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
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
# JNPF 全栈一键启动 = 微服务 + 基础设施(Redis/PG)+ 前端 cx-web,本地/服务器同一份编排
# (RocketMQ 已于 2026-08-10 移除,事件总线改 Redis pub/sub,见补丁 #10)
#
# 地址拓扑三层规则(详见 docs/docker-compose-guide.md):
#   配置/发现(补丁 #8 起):镜像烧入 config/shared 静态配置 + SimpleDiscoveryClient 静态实例列表,
#     无独立注册/配置中心容器;Dubbo LogProvider 改 registry-less 直连 dubbo://cx-platform:20880(原 cx-system,2026-08-11 聚合)
#   数据面(业务→PG/Redis):本地与交付一致=服务名直连
#     cx-postgres:5432 等(release-checklist §0.10)
#   对外宣告(ApiDomain):cx-infra(浏览器+后端双消费方;本地 hosts + 容器 extra_hosts 双侧解析)
#
# 前提:
#   1. 宿主 /etc/hosts 含「127.0.0.1 cx-infra」(本机代理软件须加 cx-infra 直连规则,L026)
#   2. 构建前先打包:mvn clean package -DskipTests
#   3. 库在哪由 .env 的 CUSTOMER_DB_HOST 决定,两种形态:
#      远端客户库(开发机/测试环境,CUSTOMER_DB_HOST=YOUR_DB_HOST):本机不起 PG,什么都不用准备
#      栈内 PG(交付/正式,CUSTOMER_DB_HOST=cx-postgres):.env 设 COMPOSE_PROFILES=localdb,
#        且全新机器先 docker volume create postgres_pgdata 并灌数据(见 docker/postgres/migration/)
#   4. 宿主上不能有同端口进程(旧 nohup 服务 / Homebrew redis 均须停)
#
# 基础设施镜像统一 linux/amd64 + 华为云 SWR 源(swr.cn-north-4.myhuaweicloud.com/ddn-k8s/...):
#   本地 Apple Silicon 经 Rosetta 跑,x86 服务器原生;一份 compose 两侧零差异
#
# ⚠️ 第三方基础设施镜像在本文件里用**短名**(cx-redis 等),
#   它们是 `make pull-infra` 从上述 SWR 长名 retag 出来的本地别名(docker tag 只挂别名、
#   不复制数据)。**因此裸 `docker compose pull` 拉不到它们**——本地没有这些 tag 时
#   Docker 会去 docker.io 找不存在的 library/cx-redis。起栈一律走 `make up`
#   (其 images 目标已含 pull-infra)或离线包的 `make load`。preflight 会在 up 前拦截缺失。
#
# 端口策略(Backlog A 安全加固,2026-07-17;Backlog F 增 80):
#   本文件=交付基线,发布 cx-web 80(页面入口)+ gateway 30000(API/ApiDomain 直连口);
#   其余端口(PG/Redis/引擎/业务调试口)在 docker-compose.override.yml(本地开发用,compose 自动合并,交付包不含)。
#   交付环境数据面服务名直连(见 docs/release-checklist.md §0),不依赖 hairpin 发布端口。
#
# 凭据策略:密码/密钥一律经 .env 注入(${VAR:?} 必填,缺失时 compose 直接报错),仓库只留 .env.example 占位。
#
# 用法:
#   cp .env.example .env                           # 首次;本地默认值可直接用,交付必须改值
#   docker compose build && docker compose up -d   # 冷启动自动串行:PG → 业务
#   docker compose --profile extra up -d           # 附带 scheduletask(app 已并入 cx-platform)
 
name: cx
 
# 公共环境变量(单独成锚点,便于某服务只覆盖个别变量而不丢其余:
#   environment:
#     <<: *cx-env
#     JAVA_TOOL_OPTIONS: -Xms512m -Xmx1536m
# )
x-cx-env: &cx-env
  # 官方 Dockerfile ENTRYPOINT 无 $JAVA_OPTS 透传,堆参数走 JVM 自动拾取的 JAVA_TOOL_OPTIONS
  # stdout/stderr.encoding(2026-08-11):JDK 19+ 起 System.out 的编码由 stdout.encoding 决定,
  # 与 Dockerfile 里已有的 -Dfile.encoding=utf8 **无关**;rocky 基础镜像没设 locale
  # (native encoding = ANSI_X3.4-1968),于是中文 println 逐字变 `?`——gateway 的
  # 「网关启动成功」在日志里就是那行 `??????`。走 JAVA_TOOL_OPTIONS 而不是改 Dockerfile,
  # 是为了零重建、且一处覆盖全部服务。
  JAVA_TOOL_OPTIONS: -Xms256m -Xmx768m -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8
  # Spring Boot 的 ASCII banner:容器日志里没人看,白占 8 行。
  # 🔴 引号不能省:YAML 1.1 里裸 off 会被解析成布尔 false,compose 拒绝布尔型 environment 值。
  SPRING_MAIN_BANNER_MODE: "off"
  # 日志落盘根目录(2026-08-11):config/shared/logger.yaml 的 log.path 占位符消费,
  # 与下方 x-cx 的 volumes 那行**成对**——改一个必须改另一个,否则日志又回到容器可写层。
  # 各服务自动写 /data/jnpf-logs/<spring.application.name>/ 子目录,故一个挂载点够全部服务用。
  JNPF_LOG_DIR: /data/jnpf-logs
  # resources.yaml: storage-path ${JNPF_FILE_DIR:/Users/Shared/}(容器内路径),
  # 文件落 <根>/jnpf-resources/
  JNPF_FILE_DIR: /data/jnpf-files/
  # 补丁 #8:域名四键(system-config.yaml 占位符消费;默认值即本机开发形态,交付在 .env 设真值)
  JNPF_API_DOMAIN: ${JNPF_API_DOMAIN:-http://cx-infra:30000}
  JNPF_FRONT_DOMAIN: ${JNPF_FRONT_DOMAIN:-http://localhost/}
  JNPF_APP_DOMAIN: ${JNPF_APP_DOMAIN:-http://localhost/}
  JNPF_FLOW_DOMAIN: ${JNPF_FLOW_DOMAIN:-http://cx-flow-engine:31000}
  # 🔴 Dubbo LogProvider 消费端直连地址(2026-08-11 平台聚合必须加)。
  # 消费方是 jnpf-common-springaop 的 RequestLogAspect / ResultException,它们的
  # @DubboReference 默认值**硬编码为 dubbo://cx-system:20880**——而 cx-system 已并入
  # cx-platform、容器不复存在,不覆盖就每分钟刷 UnknownHostException: cx-system。
  # 宿主栈(start-all.sh)设的是 dubbo://127.0.0.1:20880,不含服务名,故宿主形态**测不出**
  # 这个问题;它只在容器形态暴露。改服务名时这里必须跟着改。
  # 遗留优化项:聚合后 provider 与 consumer 同处一个 JVM,本可用 injvm 走进程内直调,
  # 省掉这一跳本地 TCP 与重连定时器;但那要改平台代码的 injvm=false,另案评估。
  JNPF_LOG_PROVIDER_URL: ${JNPF_LOG_PROVIDER_URL:-dubbo://cx-platform:20880}
  # 客户坐标(adr-multi-customer-db-isolation §3):业务库经 config/shared/datasource.yaml 的
  # ${CUSTOMER_DB_HOST}/${CUSTOMER_DB_PORT} 占位符消费,故必须透传进业务容器。
  # 与下方 cx-flow-engine 的 FLOW_DB_URL 同源——两库要么一起切要么一起不切。
  CUSTOMER_DB_HOST: ${CUSTOMER_DB_HOST:-cx-postgres}
  CUSTOMER_DB_PORT: ${CUSTOMER_DB_PORT:-5432}
  # 业务库账号也透传:config/shared/datasource.yaml 的 username/password 同样是占位符,
  # 否则每切一个客户就得改这份文件的默认值,「单一开关」就名存实亡了。
  JNPF_APP_DB_USER: ${JNPF_APP_DB_USER:-jnpf_app}
  JNPF_APP_DB_PASSWORD: ${JNPF_APP_DB_PASSWORD:?缺 JNPF_APP_DB_PASSWORD,见 .env.example}
  # Redis 密码透传给业务服务(datasource.yaml 的 spring.redis.password 占位符消费)。
  # 此前业务侧读的是 yaml 明文字面值、仅 cx-redis 容器自己吃这个 env——两边靠碰巧同值工作;
  # 参数化后交付环境改 .env 一处即可轮换(2026-08-06 用户指出后收口)。
  JNPF_REDIS_PASSWORD: ${JNPF_REDIS_PASSWORD:?缺 JNPF_REDIS_PASSWORD,见 .env.example}
  # Redis 坐标透传(datasource.yaml 的 spring.redis.host/port 占位符消费)。
  # JNPF_DOCKER_REDIS_* 是容器侧专用覆盖:宿主 JVM 可继续使用 127.0.0.1,容器则通过
  # cx-infra(host-gateway) 访问宿主 Redis。未设置专用覆盖时沿用 JNPF_REDIS_*,兼容远端 Redis。
  # 两组都不设 = 栈内 cx-redis(交付/本机容器形态,见 .env.example §5)。
  # 🔴 必须写 ${VAR:-默认值} 而非裸 ${VAR}:宿主未设时裸形式注入空串,会把 yaml 里的默认值静默清空。
  JNPF_REDIS_HOST: ${JNPF_DOCKER_REDIS_HOST:-${JNPF_REDIS_HOST:-cx-redis}}
  JNPF_REDIS_PORT: ${JNPF_DOCKER_REDIS_PORT:-${JNPF_REDIS_PORT:-6379}}
 
x-cx: &cx
  # 业务镜像当前以 /bin/sh -c 启动 Java;用 init 转发停止信号并回收子进程,
  # 避免 compose 重建时 PID 变成 zombie 后无法停止旧容器。
  init: true
  restart: unless-stopped
  networks: [cx]
  extra_hosts:
    - "cx-infra:host-gateway"
  environment: *cx-env
  # 冷启动排队(C4 补齐强依赖;补丁 #8 起业务改显式依赖 PG):
  # PG healthy 才放业务(C2 深化后=业务/流程引擎库真就绪,L022 重连空转窗口同样适用);
  # Redis=会话/缓存**兼事件总线**(2026-08-10 起,见 config/shared/system-config.yaml),
  # 起在依赖前只会刷错重连。
  # MQ namesrv 已移除:RocketMQ 于 2026-08-10 随事件总线切 Redis 一并退栈。
  depends_on:
    cx-postgres:
      condition: service_healthy
      # 连远端客户库时本机不起 PG(cx-postgres 在 localdb profile 内,见其服务块注释)。
      # required:false = 该服务不在本次启动集合里就跳过、且不自动把它的 profile 拉进来;
      # 在集合里(COMPOSE_PROFILES=localdb)则照旧等它 healthy——两种形态共用这一份编排。
      required: false
    cx-redis:
      condition: service_healthy
  volumes:
    # 与宿主进程共享同一文件存储根(混合调试两侧看到同一批文件);
    # 跨平台:宿主侧根目录用 JNPF_DATA_DIR 参数化,Win/Linux 在仓库根 .env 里
    # 设 JNPF_DATA_DIR=D:/jnpf-data 或 /data/jnpf 即可,本机 Mac 默认 /Users/Shared
    - ${JNPF_DATA_DIR:-/Users/Shared}/jnpf-resources:/data/jnpf-files/jnpf-resources
    # 应用日志落宿主(2026-08-11):与 x-cx-env 的 JNPF_LOG_DIR 成对。
    # 此前日志写在容器可写层,容器 recreate 即全丢、且不占可见磁盘配额。
    # 宿主侧长成 logs/<服务名>/log_{error,warn,info,debug,total}.log,直接 tail -f 即可;
    # 滚动归档(按天+满 10MB,保留 7 天)进 <服务名>/{error,info,warn,total}/YYYY-MM-DD/,同样在宿主上。
    # 🔴 冒号左边 = 宿主机目录(改这个),右边 = 容器内目录(由 JNPF_LOG_DIR 固定,别改)。
    # 交付现场若不想写进部署目录,在 .env 设 JNPF_LOG_DIR_HOST=/var/log/jnpf 这类宿主绝对路径。
    # ⚠️ Linux 上该目录由容器内 root 创建,非 root 用户读日志需要 sudo。
    - ${JNPF_LOG_DIR_HOST:-./logs}:/data/jnpf-logs
  logging:
    # 容器 stdout 的 json-file 轮转上限;查看用 docker compose logs -f <svc>
    # (注意这份和上面的文件日志是两套:stdout 走 json-file 存 /var/lib/docker,
    #  容器删除即消失;要留存的日志看挂出来的 logs/ 目录)
    options:
      max-size: "50m"
      max-file: "3"
 
# 基础设施公共模板(无业务 env、无文件存储卷)
x-infra: &infra
  restart: unless-stopped
  networks: [cx]
  extra_hosts:
    - "cx-infra:host-gateway"
  logging:
    options:
      max-size: "50m"
      max-file: "3"
 
services:
  # ================= 基础设施(M2 收编;调试端口见 docker-compose.override.yml) =================
 
  cx-postgres:
    <<: *infra
    container_name: cx-postgres
    # 「栈内 PG」只是两种库形态之一,故收进 profile,默认不启动(2026-08-07):
    #   开发机 / 测试环境:CUSTOMER_DB_HOST 指向 CVM 上的客户实例,本机这个 PG 是多余的
    #     (起它还会因 external 卷 postgres_pgdata 不存在而直接把整栈拦下)
    #   交付 / 正式:本机 PG 就是唯一库,须在 .env 设 COMPOSE_PROFILES=localdb 才会起
    # 谁该设这个开关有唯一判据:CUSTOMER_DB_HOST=cx-postgres 就必须设,preflight 会核对。
    # 构建与离线打包不受 profile 影响(Makefile images / assemble-offline-package.sh 显式带上),
    # 否则交付包会缺 PG 镜像。
    profiles: ["localdb"]
    # 定义自 docker/postgres/docker-compose.yml 平移(该文件已废弃);镜像本地已构建,交付走 tar
    image: cx/postgres:18.4
    build: docker/postgres
    platform: linux/amd64
    shm_size: 1g
    # 端口不在基线发布(宿主 psql 走 override 的 127.0.0.1:5433,或 docker exec cx-postgres psql)
    environment:
      TZ: Asia/Shanghai
      POSTGRES_USER: postgres
      # 仅 initdb 时生效;既有 pgdata 卷改密码须 ALTER USER(见 release-checklist §0)
      POSTGRES_PASSWORD: ${JNPF_PG_PASSWORD:?缺 JNPF_PG_PASSWORD,先 cp .env.example .env}
      # PG18 官方镜像 PGDATA 迁至 /var/lib/postgresql/18/docker,volume 挂父目录
      POSTGRES_INITDB_ARGS: "--locale=C.UTF-8 --encoding=UTF8"
    volumes:
      - pgdata:/var/lib/postgresql
    command:
      - postgres
      - -c
      - shared_preload_libraries=pg_stat_statements,auto_explain
      - -c
      - pg_stat_statements.track=all
      - -c
      - auto_explain.log_min_duration=1s
      - -c
      - timezone=Asia/Shanghai
      # 9 个业务服务 Druid 池稳态 ~98 连接,默认 100 在真实页面流量下打满(2026-07-17 回归实测)
      - -c
      - max_connections=200
    healthcheck:
      # C2 深化:pg_isready 只验"接受连接",空卷/漏灌的 PG 照样 healthy(全新机器静默失败根源)。
      # 追加两库连得上 + 关键表在:坏库不放行,下游(业务/流程引擎 service_healthy)等真就绪。
      # 实现要点:连上目标库本身即证明"库存在且接受连接"(pg_isready 冗余,省掉);
      # jnpf_xxljob 无关键表,用 1/count 除零技巧并入首个连接;共 2 次 psql——冷启动 13 个 JVM
      # 打满 CPU 时(本地 Rosetta)每个进程 spawn 都慢,多进程版实测超 5s timeout 误判 unhealthy。
      # 只读校验走容器内 socket=trust,与密码无关(L030 不适用)。
      # 补丁 #8:去掉第三库探活——原注册/配置中心的库仍在 PG 里(未删,见
      # tasks/jnpf-platform-patches.md 补丁 #8),但栈内无服务再消费它,健康检查不必再等它。
      test:
        - CMD-SHELL
        - >-
          psql -qU postgres -d jnpf_init -v ON_ERROR_STOP=1
          -c 'SELECT 1 FROM base_user LIMIT 0'
          -c "SELECT 1/count(*) FROM pg_database WHERE datname='jnpf_xxljob'" >/dev/null &&
          psql -qU postgres -d jnpf_flow -v ON_ERROR_STOP=1 -c 'SELECT 1 FROM act_ru_task LIMIT 0' >/dev/null
      interval: 15s
      # 冷启动 CPU 饱和窗口进程 spawn 慢(上);稳态 <1s,20s 只是兜底
      timeout: 20s
      retries: 5
      # 大卷 crash recovery 可能远超 5×15s,放宽到 120s:start_period 内失败不计 retries、
      # 一旦成功立即 healthy(不拖慢正常冷启动),只延后"真坏"的 unhealthy 判定
      start_period: 120s
 
  cx-redis:
    <<: *infra
    container_name: cx-redis
    image: cx-redis:8.4.0-alpine
    platform: linux/amd64
    # requirepass 经 .env 注入(config/shared/datasource.yaml 的 spring.redis.password 须同值)。
    #
    # 🔴 2026-08-10 起开持久化(此前是 `--save ""` 不落盘不挂卷)。
    # 原来的理由是「纯缓存/会话,数据可丢,反正 jnpf-oauth 每次启动会清全库」——
    # **那个理由不成立**:JnpfListener 的清库被 `"false".equals(getTestVersion())` 门控,
    # 而 test-version 全仓库未配置 → null → 条件为 false → 清库从不执行。
    # 实测(CVM 客户A):唯一无 TTL 的 key 是 `idgenerator_Index:`(ID 生成器索引,值已到 174),
    # 丢了会让雪花 workerId 复用 → 主键冲突/跳号。详见 docs/release-checklist.md §2.2。
    # 注意:事件总线走 pub/sub,**不是**开持久化的理由(在途消息落盘也救不回)。
    command:
      - redis-server
      - --protected-mode
      - "yes"
      - --requirepass
      - ${JNPF_REDIS_PASSWORD:?缺 JNPF_REDIS_PASSWORD,先 cp .env.example .env}
      # AOF 为主(everysec:最多丢 1 秒),RDB 兜底便于整卷搬迁
      - --appendonly
      - "yes"
      - --appendfsync
      - everysec
      - --save
      - "900 1"
      # 🔴 交付基线此前**既无 maxmemory 也无 mem_limit**——Redis 可以吃光整台机器。
      # 512mb 是给中小客户的保守起步值;正式环境应按实测定,方法见
      # docs/release-checklist.md §2.2「容量与告警」。
      # 不变式:若给容器加 mem_limit,必须 ≥ 2 × maxmemory + 64M(AOF rewrite 的 COW 峰值)。
      - --maxmemory
      - 512mb
      # 保持 noeviction:会话/缓存以外还有无 TTL 的 idgenerator_Index:,
      # LRU 静默逐出会变成跳号/主键冲突这类诡异故障;打满报错反而立刻暴露问题。
      # 代价是打满 = 写入全失败,所以**必须配 70% 告警**,别等撞墙。
      - --maxmemory-policy
      - noeviction
    volumes:
      - redisdata:/data
    environment:
      # redis-cli 自动读取,healthcheck 免显式 -a(避免密码出现在进程参数)
      REDISCLI_AUTH: ${JNPF_REDIS_PASSWORD}
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  # ── RocketMQ(cx-mq-namesrv / cx-mq-store-init / cx-mq-broker)已于 2026-08-10 整体移除 ──
  # 事件总线改用 Redis pub/sub(config/shared/system-config.yaml 的 event.event-publish-type: redis),
  # binder 依赖也已从 jnpf-common-springaop 排除。要回滚见 tasks/jnpf-platform-patches.md 补丁 #10。
 
  cx-flow-engine:
    <<: *infra
    container_name: cx-flow-engine
    # 独立流程引擎(官方 Flowable 7.0.1 + JNPF 定制层,自有源码工程 jnpf-flow-engine/,详见 docker/flow-engine/README.md);
    # cx-flowable(30004) 经 FlowDomain=http://cx-flow-engine:31000 服务名直连调用(§0.10 数据面规则)。
    # 构建前置:mvn -f jnpf-flow-engine/pom.xml package + ./docker/flow-engine/prepare-context.sh(jar 不入 git)
    image: cx/flow-engine:${CX_VERSION:-1.0.0}
    build: docker/flow-engine
    platform: linux/amd64
    # Dockerfile ENTRYPOINT 是 sh -c 形式(PID 1 不收割子进程),曾出僵尸进程导致容器停不掉(L031)
    init: true
    # 数据源 = PG 的 jnpf_flow 库(随 pgdata 卷,Flowable 7 同版本 schema);
    # 连库账号经 env 注入(conf/application-dev.yml 只留占位),A4 起用专用角色 jnpf_flow_app
    environment:
      # URL 用 :- 带默认值:默认容器网直连;
      # 开发机连测试环境共享库时在 .env 覆盖(docs/adr-single-db-topology.md §5.2)
      # 引擎库与业务库共用同一对坐标变量 → 物理上不可能半切(adr-multi-customer-db-isolation §4)。
      # JNPF_FLOW_DB_URL 保留为逃生口(整串覆盖),日常不要用——用它就重新引入了半切风险。
      FLOW_DB_URL: ${JNPF_FLOW_DB_URL:-jdbc:postgresql://${CUSTOMER_DB_HOST:-cx-postgres}:${CUSTOMER_DB_PORT:-5432}/jnpf_flow}
      FLOW_DB_USER: ${JNPF_FLOW_DB_USER:-postgres}
      FLOW_DB_PASSWORD: ${JNPF_FLOW_DB_PASSWORD:?缺 JNPF_FLOW_DB_PASSWORD,先 cp .env.example .env}
    depends_on:
      cx-postgres:
        condition: service_healthy
        # 同 x-cx 的说明:栈内 PG 在 localdb profile 内,不在启动集合时跳过而非报错
        required: false
    healthcheck:
      # 无 actuator,用 springdoc 文档端点探活
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:31000/v3/api-docs"]
      interval: 15s
      timeout: 5s
      start_period: 90s
      retries: 5
 
  cx-onlyoffice:
    <<: *infra
    container_name: cx-onlyoffice
    # OnlyOffice Document Server 社区版:Office 文档在线预览/编辑,由 cx-biz-common 的
    # onlyoffice 能力模块驱动(方案见 tasks/onlyoffice-integration-plan.md)。
    # 用 *infra 而非 *cx:它不是 JNPF JVM,不需要 JNPF_* 环境变量与 PG/Redis/MQ 依赖。
    #
    # ⚠️ 版本必须固定:9.4.0.1 是 Docker Hub 当前 latest(Hub manifest list digest
    #   sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212);
    #   tag 9.4.0 / 9.4 是更早的另一个 digest,不要混用。
    #
    # 🔴 2026-08-19:由 Docker Hub 长名改为短名 cx-onlyoffice,与 cx-redis 同机制——
    #   国内网络 `docker.io/onlyoffice/documentserver` 拉取超时(registry-1.docker.io:443
    #   连不上,Windows 测试环境实测)。短名由 `make pull-infra` 从 SWR 长名
    #   $SWR/onlyoffice/documentserver:9.4.0.1 retag 而来,故裸 `docker compose pull`
    #   同样拉不到它(详见本文件顶部 §短名约定与 docs/docker-compose-guide.md §11.1)。
    #   SWR 侧是已解开的 amd64 单架构 manifest(digest
    #   sha256:66e06b404e11d532832501acf5a78bc759940c58859a80676330f71aedb1ef99,
    #   config sha256:4934c364adccc2898d0998951fb09d455496454de417cce2a74b7b1e808daab0),
    #   与上面 Hub 的 manifest-list digest 天然不同,**不能据此判断内容不一致**。
    #   ✅ 2026-08-19 已实证两源内容同一(digest 比不了:本机连不通 Hub,且本地 Hub tag
    #   在 containerd image store 下只剩 index 引用,inspect 出来 Architecture 空、
    #   RootFS.Layers=0,无从取层)。改在容器内比,两侧完全一致:
    #     · dpkg 包版本   onlyoffice-documentserver 9.4.0-129(amd64)
    #     · 核心二进制    server/DocService/docservice
    #                     sha256:556df4aab7b1a87123046a4459baee34e60f57deaf7bcd189bccd6f271bd8fcb
    #   并且 SWR 镜像单起容器 20s 内跑通官方 /healthcheck。
    image: cx-onlyoffice:9.4.0.1
    platform: linux/amd64
    environment:
      # JWT 是 DS 与后端之间的唯一信任凭据:后端签发编辑器 config、DS 签发保存回调,
      # 两侧共用同一 secret。与 config/shared/jnpf-biz-common.yaml 的
      # onlyoffice.jwt-secret 同源(都读 .env 的 ONLYOFFICE_JWT_SECRET)——
      # 两边不一致的表现是编辑器一直转圈或回调静默失败,且日志不明显。
      JWT_ENABLED: "true"
      JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?缺 ONLYOFFICE_JWT_SECRET,先 cp .env.example .env}
    volumes:
      # DS 内置 PostgreSQL 存文档缓存与编辑状态,不挂卷则重启丢失(表现为编辑中的
      # 文档回到上一个保存点)。字体不挂载——镜像自带 22 个中文字体已够用(实测)。
      - onlyoffice-data:/var/www/onlyoffice/Data
      - onlyoffice-log:/var/log/onlyoffice
      - onlyoffice-db:/var/lib/postgresql
    healthcheck:
      # DS 官方探活端点,就绪时返回字面量 true
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck | grep -q true"]
      interval: 15s
      timeout: 10s
      retries: 5
      # 首次启动要初始化内置 PG + 生成字体索引,Rosetta 上偏慢
      start_period: 90s
 
  # ================= JNPF 业务服务(M1) =================
  cx-gateway:
    <<: *cx
    container_name: cx-gateway
    image: cx/gateway:${CX_VERSION:-1.0.0}
    build: { context: ., dockerfile: dockerfile/gateway/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
    ports: ["30000:30000"]
    # 说明:2026-08-11 一度在这里加过 -Dlogging.level.jnpf.util.Ip2RegionUtil=OFF 来压
    # 「ip2region xdb 找不到」的两条 ERROR。补丁 #15 从根上解决后(gateway 自带同包替身类
    # jnpf/util/Ip2RegionUtil.java,见该文件注释)已撤掉——压噪配置留着会掩盖真问题,
    # 也会让人误以为那两条错误还在。本服务现在完全走 x-cx-env 的公共环境变量。
    healthcheck: &hc-30000
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30000"]
      interval: 30s
      # 冷启动 CPU 饱和下业务真实启动 4-6 分钟,180s 会经历"瞬时 unhealthy 再自愈"的误导态
      # (三轮冷启动实测);start_period 内失败不计 retries、成功立即 healthy,放宽无副作用(L034)
      start_period: 300s
      retries: 3
 
  # ================= 平台聚合服务(2026-08-11)=================
  # system / permission / oauth / visualdev / visualdata / message / file / app / flowable
  # 九个模块合一个 JVM,取代下面九个已注释的 cx-* 容器。
  # 实测:RSS 约 1.07GB(对比九个独立容器约 6.7GB),fat jar 328MB(对比九个各 198MB)。
  # 服务名不变——config/shared/discovery.yaml 里九个 jnpf-* 名字全部别名到 cx-platform:30002,
  # 网关 router.yaml 与 60 个 @FeignClient 一行未改。
  cx-platform:
    <<: *cx
    container_name: cx-platform
    image: cx/platform:${CX_VERSION:-1.0.0}
    build: { context: ., dockerfile: dockerfile/platform/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
    environment:
      <<: *cx-env
      # 九个模块的类元数据同处一个 JVM,堆比单模块服务大一档(模板默认 -Xms256m -Xmx768m)。
      # 实测堆 used 约 361MB,1536m 留足余量;即便如此总占用仍远低于九容器形态。
      # ⚠️ 本行整体覆盖 x-cx-env 的同名值,编码参数必须跟着抄一份,否则本服务中文日志变问号。
      JAVA_TOOL_OPTIONS: -Xms512m -Xmx1536m -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8
      # 原 jnpf-system 是 Dubbo LogProvider 唯一 provider(补丁 #8),聚合后本服务接管。
      # 不设此项 → 每个请求的 RequestLogAspect 都调用失败。
      DUBBO_PROTOCOL_PORT: "20880"
    healthcheck:
      <<: *hc-30000
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30002"]
 
  # ── cx-oauth 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ──
  # cx-oauth:
  #   <<: *cx
  #   container_name: cx-oauth
  #   image: cx/oauth:${CX_VERSION:-1.0.0}
  #   build: { context: ., dockerfile: dockerfile/oauth/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
  #   healthcheck:
  #     <<: *hc-30000
  #     test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30001"]
 
 
  # ── cx-system 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ──
  # cx-system:
  #   <<: *cx
  #   container_name: cx-system
  #   image: cx/system:${CX_VERSION:-1.0.0}
  #   build: { context: ., dockerfile: dockerfile/system/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
  #   environment:
  #     <<: *cx-env
  #     # 补丁 #8:jnpf-system 是 Dubbo LogProvider 唯一 provider,固定协议端口供直连
  #     DUBBO_PROTOCOL_PORT: "20880"
  #   healthcheck:
  #     <<: *hc-30000
  #     test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30002"]
 
 
  # ── cx-visualdev 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ──
  # cx-visualdev:
  #   <<: *cx
  #   container_name: cx-visualdev
  #   image: cx/visualdev:${CX_VERSION:-1.0.0}
  #   build: { context: ., dockerfile: dockerfile/visualdev/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
  #   healthcheck:
  #     <<: *hc-30000
  #     test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30003"]
 
 
  # ── cx-flowable 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ──
  # cx-flowable:
  #   <<: *cx
  #   container_name: cx-flowable
  #   image: cx/flowable:${CX_VERSION:-1.0.0}
  #   build: { context: ., dockerfile: dockerfile/flowable/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
  #   healthcheck:
  #     <<: *hc-30000
  #     test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30004"]
 
 
  # ── cx-file 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ──
  # cx-file:
  #   <<: *cx
  #   container_name: cx-file
  #   image: cx/file:${CX_VERSION:-1.0.0}
  #   build: { context: ., dockerfile: dockerfile/file/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
  #   healthcheck:
  #     <<: *hc-30000
  #     test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30005"]
 
 
  # ── cx-message 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ──
  # cx-message:
  #   <<: *cx
  #   container_name: cx-message
  #   image: cx/message:${CX_VERSION:-1.0.0}
  #   build: { context: ., dockerfile: dockerfile/message/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
  #   healthcheck:
  #     <<: *hc-30000
  #     test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30008"]
 
 
  # ── cx-permission 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ──
  # cx-permission:
  #   <<: *cx
  #   container_name: cx-permission
  #   image: cx/permission:${CX_VERSION:-1.0.0}
  #   build: { context: ., dockerfile: dockerfile/permission/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
  #   healthcheck:
  #     <<: *hc-30000
  #     test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30010"]
 
 
  cx-lims:
    <<: *cx
    container_name: cx-lims
    image: cx/lims:${CX_VERSION:-1.0.0}
    build: { context: ., dockerfile: dockerfile/lims/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
    environment:
      <<: *cx-env
      # Spring relaxed binding → audit.spool-dir(AuditSdkAutoConfiguration @Value("${audit.spool-dir:./audit-spool}"))。
      # cx-lims 是当前**唯一**的 audit-sdk 宿主,也就是唯一需要 spool 兜底的服务。
      AUDIT_SPOOL_DIR: /jnpf/audit-spool
      # M2 Task A1:audit-sdk 已接入 lims。relaxed binding 自动映射到 audit.enabled(总开关,
      # AuditSdkAutoConfiguration @ConditionalOnProperty),无需在 application.yml 重复声明占位符。
      # 双态断言见 tasks/audit-m2-startup-check.sh。
      AUDIT_ENABLED: ${AUDIT_ENABLED:-true}
      # 2026-08-11:AUDIT_LAYER1_ENABLED / AUDIT_LAYER1_PER_TABLE_MASKED_COLUMNS 随层 1 整套移除。
      # 层 1 自交付起一直默认 false,audit_events 表内 source_layer=1 零行,属未启用即负债。
    volumes:
      # ⚠️ 服务级 volumes 会**整体覆盖** x-cx 锚点的那份,不是追加——所以 jnpf-resources
      # 与日志挂载都得在这里再写一遍,漏一行该服务就静默退回容器内路径(2026-08-11 踩过)。
      - ${JNPF_DATA_DIR:-/Users/Shared}/jnpf-resources:/data/jnpf-files/jnpf-resources
      - ${JNPF_LOG_DIR_HOST:-./logs}:/data/jnpf-logs
      - audit-spool-lims:/jnpf/audit-spool
    healthcheck:
      <<: *hc-30000
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30019"]
 
  # eln 业务模块(ADR 售卖单元制):profile 隔离,默认 make up 不起(本地 VM 内存账,L024)。
  # 起用:docker compose --profile eln up -d
  cx-eln:
    <<: *cx
    container_name: cx-eln
    image: cx/eln:${CX_VERSION:-1.0.0}
    profiles: ["eln"]
    build: { context: ., dockerfile: dockerfile/eln/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
    healthcheck:
      <<: *hc-30000
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30013"]
 
  # ══ cx-audit(30014)已于 2026-08-11 退役 ══
  # 审计整体搬入 jnpf-biz-common(见下),JVM/容器/镜像各减一个。
  # 服务名 jnpf-audit 在 config/shared/discovery.yaml 里保留为**别名**指向 cx-biz-common:30015,
  # 故 lims 的 Feign 投递与 /api/audit/** 路由一行未改。
  # 原先这里的 AUDIT_SPOOL_DIR + audit-spool-audit 卷是**从未生效的配置**:spool 属 audit-sdk,
  # 而 audit 服务端的依赖链(server→controller→biz→api)从不含 sdk——退役前实测该卷内始终为空。
 
  # 公共业务能力聚合服务(jnpf-biz-common):默认启动(无 profiles,区别于 cx-eln)。
  # 一个 JVM 承载多个能力模块(onlyoffice、audit…),准入纪律见 specs/2026-08-07-biz-common-design.md §3
  cx-biz-common:
    <<: *cx
    container_name: cx-biz-common
    image: cx/biz-common:${CX_VERSION:-1.0.0}
    build: { context: ., dockerfile: dockerfile/biz-common/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
    # 只有本服务消费这个密钥,故不进公共 *cx-env 锚点,单独覆盖(保留锚点其余变量)。
    # 🔴 漏了它会静默失败而不是报错:config/shared/jnpf-biz-common.yaml 里写的是
    # ${ONLYOFFICE_JWT_SECRET},而 Spring Boot 的 Binder 对解析不了的占位符是原样放行,
    # 后端会拿字面量 "${ONLYOFFICE_JWT_SECRET}" 当密钥签名,DS 验签必然失败,
    # 表现为编辑器里「文档安全令牌的格式不正确」——2026-08-08 实踩。
    environment:
      <<: *cx-env
      ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?缺 ONLYOFFICE_JWT_SECRET,先 cp .env.example .env}
    healthcheck:
      <<: *hc-30000
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30015"]
 
  cx-dms:
    <<: *cx
    container_name: cx-dms
    image: cx/dms:${CX_VERSION:-1.0.0}
    build: { context: ., dockerfile: dockerfile/dms/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
    healthcheck:
      <<: *hc-30000
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30016"]
 
  # ── cx-visualdata 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ──
  # cx-visualdata:
  #   <<: *cx
  #   container_name: cx-visualdata
  #   image: cx/visualdata:${CX_VERSION:-1.0.0}
  #   build: { context: ., dockerfile: dockerfile/visualdata/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
  #   healthcheck:
  #     <<: *hc-30000
  #     test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30011"]
 
  # ================= 前端(Backlog F)=================
  # nginx 静态托管 dist + /api、/websocket 反代 gateway;非 JVM 用 *infra 模板(x-cx 的
  # JAVA_TOOL_OPTIONS/文件卷/depends_on 均不适用)。不加 depends_on:nginx.conf 用变量
  # proxy_pass + resolver 惰性解析,gateway 未起时 nginx 照常启动、重建换 IP 自动重解析。
  # dist 由 make web-dist 快照进 docker/frontend/dist/(独立 build context——根 .dockerignore
  # 是 deny-all 白名单,context: . 拿不到前端文件)。
  cx-web:
    <<: *infra
    container_name: cx-web
    image: cx/web:${CX_VERSION:-1.0.0}
    build:
      context: docker/frontend
      args:
        NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
    ports:
      - "80:80"
      # - "443:443"    # TLS 现场启用(证书 + nginx.conf 443 块,三步见交付包 INSTALL.md §5)
    volumes:
      # 挂载为准(现场免构建改反代/开 TLS),镜像内 COPY 的同份 conf 作自包含兜底
      - ./docker/frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      # - ./docker/frontend/certs:/etc/nginx/certs:ro    # TLS 证书挂载点
    healthcheck:
      # alpine 无 bash 用不了 &hc-30000 的 /dev/tcp;busybox wget 探静态入口即可
      test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
 
  # ---- 以下 2 个本地暂未使用,按需 --profile extra 启动 ----
 
  cx-scheduletask:
    <<: *cx
    profiles: [extra]
    container_name: cx-scheduletask
    image: cx/scheduletask:${CX_VERSION:-1.0.0}
    build: { context: ., dockerfile: dockerfile/scheduletask/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
    healthcheck:
      <<: *hc-30000
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30009"]
 
  # ── cx-app 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ──
  # cx-app:
  #   <<: *cx
  #   profiles: [extra]
  #   container_name: cx-app
  #   image: cx/app:${CX_VERSION:-1.0.0}
  #   build: { context: ., dockerfile: dockerfile/app/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } }
  #   healthcheck:
  #     <<: *hc-30000
  #     test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30012"]
 
 
networks:
  cx:
    name: cx
 
volumes:
  # Redis 持久化卷(AOF+RDB)。普通内部卷:`down -v` 会删,
  # 但 idgenerator_Index: 丢失只影响雪花 workerId 分配,重建即可,不是跨机迁移数据。
  redisdata:
  # 复用 PG 迁移期创建的既有卷(原 docker/postgres 项目);external=compose 永不删除(含 down -v),
  # 全新机器需先 docker volume create postgres_pgdata 再灌数据(docker/postgres/migration/)
  pgdata:
    name: postgres_pgdata
    external: true
  # audit-sdk spool 兜底目录(两级降级的最后一级):per-service 独立卷(Codex 批次二 #2:
  # 多实例共享 spool 卷会有孤儿租约风险,per-service 隔离规避)。
  # 2026-08-11:audit-spool-audit 随 cx-audit 退役一并删除(它从未被写入过——spool 属 sdk,
  # 而 audit 服务端不含 sdk,退役前实测卷内为空)。当前只有 SDK 宿主 cx-lims 需要。
  audit-spool-lims:
  # OnlyOffice DS 状态:Data=文档缓存与转换产物,db=内置 PG(编辑会话状态),log=日志。
  # 均为可重建数据(丢失只影响编辑中的未保存内容,已保存的文档在 jnpf-file 存储里),
  # 故用普通内部卷,不设 external
  onlyoffice-data:
  onlyoffice-log:
  onlyoffice-db: