From 9e0d2f6cdb78100ee4bb591db1795fd1db7372d4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 15:25:30 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=B5=B7=E5=A4=96PDA?= =?UTF-8?q?=E5=80=99=E9=80=89=E9=AA=8C=E6=94=B6=E9=97=A8=E7=A6=81=E5=88=A4?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phase4-overseas-pda-contract.test.mjs | 2 ++ .../phase4-overseas-pda.mjs | 20 ++++++++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 4cd77d951..0cc97a3df 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -96,6 +96,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /item\?\.shippingReady === false/) assert.match(source, /!expectedSet\.has\(id\(item\?\.containerNo\)\)/) assert.match(source, /unexpectedAwaiting=/) + assert.match(source, /candidatesByContainer\.get\(containerNo\)\?\.operable === true/) + assert.match(source, /candidatesByContainer\.get\(containerNo\)\?\.operable !== true/) assert.match(source, /missingPickupReady\.length === 0 && unexpectedAwaiting\.length === 0/) assert.doesNotMatch(source, /shippingReady == null|shippingReady !== true/) assert.match(source, /unexpectedHistorical=/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 753ed739e..b01062fb3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -321,22 +321,24 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer const known = new Set(allProducers.map((item) => id(item?.containerNo)).filter(nonBlank)) if (!expected.length) return false let response = null - let seen = new Set() + let candidatesByContainer = new Map() for (let attempt = 1; attempt <= 30; attempt += 1) { response = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] - seen = new Set(candidateRows.map((item) => id(item?.containerNo)).filter(nonBlank)) - if (expected.every((containerNo) => seen.has(containerNo)) - && awaiting.every((containerNo) => !seen.has(containerNo))) break + candidatesByContainer = new Map(candidateRows + .map((item) => [id(item?.containerNo), item]).filter(([containerNo]) => nonBlank(containerNo))) + if (expected.every((containerNo) => candidatesByContainer.get(containerNo)?.operable === true) + && awaiting.every((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true)) break await new Promise((resolve) => setTimeout(resolve, 1000)) } - const seenExpected = expected.filter((containerNo) => seen.has(containerNo)) - const missingPickupReady = expected.filter((containerNo) => !seen.has(containerNo)) - const unexpectedAwaiting = awaiting.filter((containerNo) => seen.has(containerNo)) - const unexpectedHistorical = [...seen].filter((containerNo) => !known.has(containerNo)) + const seenExpected = expected.filter((containerNo) => candidatesByContainer.has(containerNo)) + const missingPickupReady = expected.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true) + const unexpectedAwaiting = awaiting.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable === true) + const unexpectedHistorical = [...candidatesByContainer.keys()].filter((containerNo) => !known.has(containerNo)) const targetCorrelated = producers.every((item) => item?.shippingReady === true - && nonBlank(item?.carrierScheduleQueryId) && seen.has(id(item?.containerNo))) + && nonBlank(item?.carrierScheduleQueryId) + && candidatesByContainer.get(id(item?.containerNo))?.operable === true) const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready, `targetCorrelated=${targetCorrelated}, expected=${expected.join('|')}, awaiting=${awaiting.join('|') || 'none'}, seenExpected=${seenExpected.join('|') || 'none'}, missingPickupReady=${missingPickupReady.join('|') || 'none'}, unexpectedAwaiting=${unexpectedAwaiting.join('|') || 'none'}, unexpectedHistorical=${unexpectedHistorical.join('|') || 'none'}; ${trace(response)}`, OPTIONS) From 25c4dd50b3c7109a330a1c007563086a317273ab Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 15:30:29 +0800 Subject: [PATCH 2/3] fix(deploy): bound infrastructure container logs --- deploy/docker-compose.rocketmq.yml | 9 +++++++++ docker-compose-infra.yml | 9 +++++++++ scripts/container-logging-contract.test.mjs | 21 +++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/deploy/docker-compose.rocketmq.yml b/deploy/docker-compose.rocketmq.yml index b97a63ebb..4a5038dcd 100644 --- a/deploy/docker-compose.rocketmq.yml +++ b/deploy/docker-compose.rocketmq.yml @@ -1,10 +1,17 @@ name: firstleg-${DEPLOY_ENV:-test}-rocketmq +x-bounded-logging: &bounded-logging + driver: local + options: + max-size: ${DOCKER_LOG_MAX_SIZE:-50m} + max-file: "${DOCKER_LOG_MAX_FILE:-3}" + services: rocketmq-namesrv: image: apache/rocketmq:5.3.1 restart: unless-stopped command: sh mqnamesrv + logging: *bounded-logging networks: [infra-network] rocketmq-broker: @@ -17,6 +24,7 @@ services: - ./rocketmq:/config:ro - ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store command: sh /config/render-broker-config.sh + logging: *bounded-logging networks: [infra-network] rocketmq-topic-init: @@ -46,6 +54,7 @@ services: -t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6' # Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。 restart: on-failure + logging: *bounded-logging networks: [infra-network] networks: diff --git a/docker-compose-infra.yml b/docker-compose-infra.yml index bc83b8b6e..52cddb8f0 100644 --- a/docker-compose-infra.yml +++ b/docker-compose-infra.yml @@ -1,5 +1,11 @@ name: firstleg-test-infra +x-bounded-logging: &bounded-logging + driver: local + options: + max-size: ${DOCKER_LOG_MAX_SIZE:-50m} + max-file: "${DOCKER_LOG_MAX_FILE:-3}" + services: nacos: image: docker.m.daocloud.io/nacos/nacos-server:v2.3.2 @@ -22,6 +28,7 @@ services: retries: 20 start_period: 30s restart: unless-stopped + logging: *bounded-logging networks: [baiyue] mysql: @@ -46,6 +53,7 @@ services: retries: 20 start_period: 30s restart: unless-stopped + logging: *bounded-logging networks: [baiyue] redis: @@ -62,6 +70,7 @@ services: timeout: 5s retries: 10 restart: unless-stopped + logging: *bounded-logging networks: [baiyue] volumes: diff --git a/scripts/container-logging-contract.test.mjs b/scripts/container-logging-contract.test.mjs index 440849fa7..a504306b6 100644 --- a/scripts/container-logging-contract.test.mjs +++ b/scripts/container-logging-contract.test.mjs @@ -28,6 +28,11 @@ const composeFiles = [ 'deploy/docker-compose.overseas.yml', ]; +const infrastructureComposeFiles = [ + ['deploy/docker-compose.rocketmq.yml', 3], + ['docker-compose-infra.yml', 3], +]; + test('deployable applications log only to stdout and stderr', () => { for (const path of applicationConfigs) { const source = readFileSync(path, 'utf8'); @@ -52,3 +57,19 @@ test('runtime image does not prepare an application log directory', () => { const dockerfile = readFileSync('deploy/docker/Dockerfile', 'utf8'); assert.doesNotMatch(dockerfile, /\/app\/logs/); }); + +test('infrastructure containers also use bounded local logs', () => { + for (const [path, expectedServiceCount] of infrastructureComposeFiles) { + const source = readFileSync(path, 'utf8'); + assert.match(source, /x-bounded-logging:\s+&bounded-logging/, path); + assert.match(source, /driver:\s+local/, path); + assert.match(source, /DOCKER_LOG_MAX_SIZE/, path); + assert.match(source, /DOCKER_LOG_MAX_FILE/, path); + assert.equal( + source.match(/logging:\s+\*bounded-logging/g)?.length, + expectedServiceCount, + path, + ); + assert.doesNotMatch(source, /driver:\s+json-file/, path); + } +}); From 2902ab60771a30d5ad8de7a551d69b5678c4a025 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 15:39:58 +0800 Subject: [PATCH 3/3] fix(deploy): prioritize BaoTa-visible container logs --- README.md | 14 ++++++++------ deploy/docker-compose.overseas.yml | 4 ++-- deploy/docker-compose.rocketmq.yml | 2 +- deploy/docker-compose.yml | 4 ++-- docker-compose-infra.yml | 2 +- scripts/README.md | 2 +- scripts/container-logging-contract.test.mjs | 10 +++++----- 7 files changed, 20 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index de846c17a..08d975bb2 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,17 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务 ## 容器日志 -所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外 Compose 统一使用 Docker `local` 日志驱动;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 +所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖: -| 环境 | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 | -|---|---:|---:|---| -| 开发 | `20m` | `2` | 不要求 | -| 测试 | `50m` | `3` | 不要求 | -| 生产 | `100m` | `5` | 必须由集中日志系统负责,Docker 本机日志只作短期缓冲 | +| 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 | +|---|---|---:|---:|---| +| 开发 | `json-file` | `20m` | `2` | 不要求 | +| 测试 | `json-file` | `50m` | `3` | 不要求 | +| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 | + +当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。 调整日志策略后必须重新创建容器;只重启既有容器不会改变其日志驱动。历史 `/data/logs/baiyue-firstleg` 文件和旧容器日志不自动删除,确认无需保留后再单独清理。 diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 85d7534ba..274f284f9 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -26,8 +26,8 @@ x-overseas-common: &overseas-common SPRING_SQL_INIT_MODE: never TZ: Asia/Shanghai logging: - # 应用只写 stdout/stderr;local 驱动负责单份本机日志与轮转。 - driver: local + # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 + driver: ${DOCKER_LOG_DRIVER:-json-file} options: max-size: ${DOCKER_LOG_MAX_SIZE:-50m} max-file: "${DOCKER_LOG_MAX_FILE:-3}" diff --git a/deploy/docker-compose.rocketmq.yml b/deploy/docker-compose.rocketmq.yml index 4a5038dcd..f15310c7a 100644 --- a/deploy/docker-compose.rocketmq.yml +++ b/deploy/docker-compose.rocketmq.yml @@ -1,7 +1,7 @@ name: firstleg-${DEPLOY_ENV:-test}-rocketmq x-bounded-logging: &bounded-logging - driver: local + driver: ${DOCKER_LOG_DRIVER:-json-file} options: max-size: ${DOCKER_LOG_MAX_SIZE:-50m} max-file: "${DOCKER_LOG_MAX_FILE:-3}" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0caf03dec..c9175db39 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -28,8 +28,8 @@ x-common: &common DATA_RESIDENCY: ${DATA_RESIDENCY:-CN} TZ: Asia/Shanghai logging: - # 应用只写 stdout/stderr;local 驱动负责单份本机日志与轮转。 - driver: local + # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 + driver: ${DOCKER_LOG_DRIVER:-json-file} options: max-size: ${DOCKER_LOG_MAX_SIZE:-50m} max-file: "${DOCKER_LOG_MAX_FILE:-3}" diff --git a/docker-compose-infra.yml b/docker-compose-infra.yml index 52cddb8f0..5b4bc537e 100644 --- a/docker-compose-infra.yml +++ b/docker-compose-infra.yml @@ -1,7 +1,7 @@ name: firstleg-test-infra x-bounded-logging: &bounded-logging - driver: local + driver: ${DOCKER_LOG_DRIVER:-json-file} options: max-size: ${DOCKER_LOG_MAX_SIZE:-50m} max-file: "${DOCKER_LOG_MAX_FILE:-3}" diff --git a/scripts/README.md b/scripts/README.md index 407d39846..ecb55e169 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -131,7 +131,7 @@ node scripts/create-pending-review-orders.mjs | Mapper 注册静态合同 | `node scripts/mapper-scan-contract.mjs`、`node --test scripts/mapper-scan-contract.test.mjs`;自动核对全部持久化模块的 `@Mapper`、服务根包注解过滤扫描和唯一扫描入口,不启动 Spring、不连接数据库;`backend-full-regression.ps1` 在 Maven 前强制执行 | | Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | -| 容器日志单写合同 | `scripts/container-logging-contract.test.mjs`;离线确认应用只写标准输出、Compose 使用有界 `local` 驱动且不挂载应用日志目录 | +| 容器日志单写合同 | `scripts/container-logging-contract.test.mjs`;离线确认应用只写标准输出、Compose 默认使用宝塔可见的有界 `json-file` 驱动且不挂载应用日志目录 | Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: diff --git a/scripts/container-logging-contract.test.mjs b/scripts/container-logging-contract.test.mjs index a504306b6..3fa84cdc5 100644 --- a/scripts/container-logging-contract.test.mjs +++ b/scripts/container-logging-contract.test.mjs @@ -40,10 +40,10 @@ test('deployable applications log only to stdout and stderr', () => { } }); -test('Compose uses one bounded local log stream without application log mounts', () => { +test('Compose uses one bounded BaoTa-compatible log stream without application log mounts', () => { for (const path of composeFiles) { const source = readFileSync(path, 'utf8'); - assert.match(source, /driver:\s+local/, path); + assert.match(source, /driver:\s+\$\{DOCKER_LOG_DRIVER:-json-file\}/, path); assert.match(source, /DOCKER_LOG_MAX_SIZE/, path); assert.match(source, /DOCKER_LOG_MAX_FILE/, path); assert.doesNotMatch(source, /LOG_PATH|LOG_ROOT|\/app\/logs/, path); @@ -58,11 +58,11 @@ test('runtime image does not prepare an application log directory', () => { assert.doesNotMatch(dockerfile, /\/app\/logs/); }); -test('infrastructure containers also use bounded local logs', () => { +test('infrastructure containers also use bounded BaoTa-compatible logs', () => { for (const [path, expectedServiceCount] of infrastructureComposeFiles) { const source = readFileSync(path, 'utf8'); assert.match(source, /x-bounded-logging:\s+&bounded-logging/, path); - assert.match(source, /driver:\s+local/, path); + assert.match(source, /driver:\s+\$\{DOCKER_LOG_DRIVER:-json-file\}/, path); assert.match(source, /DOCKER_LOG_MAX_SIZE/, path); assert.match(source, /DOCKER_LOG_MAX_FILE/, path); assert.equal( @@ -70,6 +70,6 @@ test('infrastructure containers also use bounded local logs', () => { expectedServiceCount, path, ); - assert.doesNotMatch(source, /driver:\s+json-file/, path); + assert.doesNotMatch(source, /driver:\s+local/, path); } });