From d409094226717cc923f09cca38612f4bd87a8dbc Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 6 Nov 2026 10:42:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/cross-border/api/businessOrders.ts | 1 + src/modules/cross-border/businessOrders.test.mjs | 11 +++++++++++ .../cross-border/components/BusinessOrdersView.vue | 4 +++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts index d848f18..cdefde6 100644 --- a/src/modules/cross-border/api/businessOrders.ts +++ b/src/modules/cross-border/api/businessOrders.ts @@ -70,6 +70,7 @@ export interface TransportDetail { destinationAddress: string | null remark: string | null histories?: Array<{ operationType: string; toStatus: string; remark: string | null }> + dispatchNos?: string[] } export interface TransportCreate { diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index ee6d66c..c32f40f 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -237,6 +237,17 @@ test('looseboard saves split contacts and both local minute times without obsole for (const field of ['palletRequired', 'transportType', 'transportMode', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, field), false) }) +test('looseboard details show every dispatch without a fleet booking and retain the unassigned state', () => { + const looseboard = transportHarness('looseboard') + looseboard.detail.value = { summary: { fleetArrangement: null }, dispatchNos: ['PC-1', 'PC-2'] } + assert.equal(new Map(looseboard.detailFields.value).get('排车单号'), 'PC-1、PC-2') + looseboard.detail.value.dispatchNos = [] + assert.equal(new Map(looseboard.detailFields.value).get('排车单号'), '暂未排车') + const truck = transportHarness('trucking') + truck.detail.value = { summary: {} } + assert.equal(new Map(truck.detailFields.value).has('排车单号'), false) +}) + test('looseboard requires both times and rejects backwards schedule', () => { const current = transportHarness('looseboard') assert.equal(current.rules.value.pickupAt[0].required, true) diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue index 199c550..ea5d3dc 100644 --- a/src/modules/cross-border/components/BusinessOrdersView.vue +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -145,6 +145,7 @@ const detailFields = computed(() => { const current = detail.value as TransportDetail return [ ...(props.kind === 'looseboard' ? [ + ['排车单号', current.dispatchNos?.length ? current.dispatchNos.join('、') : '暂未排车'], ['商品名称', summary.productName], ['预计托数', summary.estimatedPalletCount], ] : [['车辆类型', vehicleTypeLabel(summary.vehicleType)]]), [props.kind === 'trucking' ? '取货地' : '起运地', read(summary, 'origin')], [props.kind === 'trucking' ? '取货地址' : '起运地址', current.originAddress], @@ -253,8 +254,9 @@ function getRowActions(candidate: object): TableAction[] { return [{ key: 'detail', label: '详情', run: () => openDetail(candidate) }] } +/** 长地址和多张排车单保留完整单号,按详情已有网格换行展示。 */ function detailFieldClass(label: unknown) { - if (label === '备注') return 'is-full' + if (label === '备注' || label === '排车单号') return 'is-full' return [props.kind === 'trucking' ? '取货地址' : '起运地址', props.kind === 'trucking' ? '送达地址' : '目的地址'].includes(String(label)) ? 'is-wide' : '' } -- 2.54.0 From c116817b5dfb5d2027cf0115e359387e08dc0df1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 6 Nov 2026 12:52:16 +0800 Subject: [PATCH 2/3] fix(ci): bound Docker image build resources --- Dockerfile | 3 ++ Jenkinsfile | 9 +++- README.md | 2 + scripts/build-image.sh | 18 +++++++ scripts/build-resource-contract.test.mjs | 67 ++++++++++++++++++++++++ scripts/deploy-production.sh | 2 +- 6 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 scripts/build-image.sh create mode 100644 scripts/build-resource-contract.test.mjs diff --git a/Dockerfile b/Dockerfile index 77cd790..b89f18e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ FROM ${NODE_IMAGE} AS build WORKDIR /app +# 为类型检查和打包留出原生内存余量,容器总内存由构建入口限制为 3GiB。 +ENV NODE_OPTIONS=--max-old-space-size=2048 + # Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。 ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com diff --git a/Jenkinsfile b/Jenkinsfile index 19da01a..0c9fed6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,6 +3,7 @@ pipeline { options { timestamps() disableConcurrentBuilds() + timeout(time: 20, unit: 'MINUTES') } stages { stage('检查测试发布参数') { @@ -27,8 +28,12 @@ pipeline { when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { sh '''set -eu - docker compose -f docker-compose.test.yml build --pull=false - docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120 + sh scripts/build-image.sh --pull=false \ + --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ + --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ + --build-arg VITE_ADMIN_WEB_ORIGIN="$VITE_ADMIN_WEB_ORIGIN" \ + -t "client-web-test:$IMAGE_TAG" . + docker compose -f docker-compose.test.yml up -d --no-build --pull never --wait --wait-timeout 120 docker compose -f docker-compose.test.yml exec -T client-web wget -qO- http://127.0.0.1/healthz ''' } diff --git a/README.md b/README.md index 36f3f95..9a54941 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,5 @@ pnpm test 仅保留 `docker-compose.test.yml` 与 test Jenkins Job。发布前注入 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`VITE_ADMIN_WEB_ORIGIN`(管理后台 origin,构建时用于安全代登录)与 `IMAGE_TAG`(Jenkins 从当前提交生成)。`HOST_PORT` 默认 9002 且仅绑定 loopback;宿主机反向代理负责外部入口。可选 `NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE`,私有镜像认证由构建节点管理。不得把本地构建结果描述为测试环境已发布。 同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。 + +Jenkins 的测试及灰度镜像构建均通过 `scripts/build-image.sh`,Compose 只部署已构建镜像。构建预算及节点要求见[部署指南](../../docs/测试环境Jenkins部署指南.md)。离线回归为 `node --test scripts/build-resource-contract.test.mjs`,本机仍经统一 `local-check.ps1` 入口运行。 diff --git a/scripts/build-image.sh b/scripts/build-image.sh new file mode 100644 index 0000000..a617456 --- /dev/null +++ b/scripts/build-image.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +# 构建前至少保留 1GiB 给业务服务,避免在宿主机内存不足时继续启动构建。 +available_kib=$(awk '$1 == "MemAvailable:" { print $2 }' /proc/meminfo) +case "$available_kib" in + ''|*[!0-9]*) echo 'Cannot read host available memory; build refused.' >&2; exit 69 ;; +esac +if [ "$available_kib" -lt 4194304 ]; then + echo 'Docker build requires at least 4GiB available host memory; retry after resources recover.' >&2 + exit 75 +fi + +# 当前节点使用 Docker 26 的经典构建器。显式固定后端,确保资源参数实际生效。 +# 限制覆盖每个 RUN 容器,失败时也清理中间容器,超时由宿主机独立终止。 +exec env DOCKER_BUILDKIT=0 timeout --signal=TERM --kill-after=30s 15m \ + docker build --memory=3g --memory-swap=3g \ + --cpu-period=100000 --cpu-quota=200000 --force-rm "$@" diff --git a/scripts/build-resource-contract.test.mjs b/scripts/build-resource-contract.test.mjs new file mode 100644 index 0000000..12ec275 --- /dev/null +++ b/scripts/build-resource-contract.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const wrapper = join(root, 'scripts/build-image.sh') +const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash' +const shellAvailable = process.platform !== 'win32' || existsSync(bash) + +// 使用替身验证参数、内存预检和退出码,不启动 Docker 或访问部署环境。 +function runBuild(availableMemory, childStatus = '0') { + const directory = mkdtempSync(join(tmpdir(), 'frontend-build-resource-')) + try { + writeFileSync(join(directory, 'awk'), '#!/bin/sh\nprintf "%s\\n" "$TEST_AVAILABLE_MEMORY"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" watchdog "$@"\nshift 3\nexec "$@"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'docker'), '#!/bin/sh\nprintf "%s\\n" "builder=$DOCKER_BUILDKIT" "$@"\nexit "$TEST_CHILD_STATUS"\n', { mode: 0o755 }) + return spawnSync(bash, ['-c', + 'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"', + 'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'), + '--build-arg', 'VALUE=with space', '-t', 'web:test', '.'], { + encoding: 'utf8', timeout: 10000, + env: { ...process.env, DOCKER_BUILDKIT: '1', TEST_AVAILABLE_MEMORY: availableMemory, TEST_CHILD_STATUS: childStatus } + }) + } finally { + assert.equal(dirname(resolve(directory)), resolve(tmpdir())) + rmSync(directory, { recursive: true }) + } +} + +test('TEST and gray builds share the resource gate and deployment cannot rebuild implicitly', () => { + const pipeline = readFileSync(join(root, 'Jenkinsfile'), 'utf8') + const production = readFileSync(join(root, 'scripts/deploy-production.sh'), 'utf8') + const dockerfile = readFileSync(join(root, 'Dockerfile'), 'utf8') + for (const source of [pipeline, production]) { + assert.match(source, /sh scripts\/build-image\.sh/) + assert.doesNotMatch(source, /docker (?:compose[^\n]* build|build\s)/) + } + assert.match(pipeline, /timeout\(time: 20, unit: 'MINUTES'\)/) + assert.match(pipeline, /up -d --no-build --pull never --wait/) + assert.ok(pipeline.includes(`-t "${basename(root)}-test:$IMAGE_TAG"`)) + assert.match(dockerfile, /^ENV NODE_OPTIONS=--max-old-space-size=2048$/m) +}) + +test('build forwards quoted arguments, enforces container budgets and propagates failure', { skip: !shellAvailable }, () => { + const result = runBuild('4194304', '23') + assert.equal(result.status, 23, result.stderr) + assert.match(result.stdout, /watchdog\r?\n--signal=TERM\r?\n--kill-after=30s\r?\n15m\r?\ndocker\r?\nbuild/) + assert.match(result.stdout, /builder=0\r?\nbuild/) + for (const argument of ['--memory=3g', '--memory-swap=3g', '--cpu-period=100000', '--cpu-quota=200000', '--force-rm']) { + assert.ok(result.stdout.split(/\r?\n/).includes(argument), argument) + } + assert.match(result.stdout, /--build-arg\r?\nVALUE=with space\r?\n-t\r?\nweb:test\r?\n\./) + assert.equal(runBuild('8388608').status, 0) + assert.equal(runBuild('8388608', '124').status, 124) +}) + +test('low or unreadable host memory refuses to start Docker', { skip: !shellAvailable }, () => { + for (const [memory, status] of [['4194303', 75], ['', 69], ['unknown', 69]]) { + const result = runBuild(memory) + assert.equal(result.status, status, result.stderr) + assert.equal(result.stdout, '') + } +}) diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index ebcb234..a59a72a 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -35,7 +35,7 @@ DOCKER_CONFIG=$(mktemp -d) cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } trap cleanup EXIT HUP INT TERM printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry" -docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ +sh scripts/build-image.sh --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ --build-arg VITE_ADMIN_WEB_ORIGIN="$PUBLIC_WEB_ORIGIN" -t "$image" . docker push "$image" -- 2.54.0 From 97bc66f28cba4db01145ccc460f29704ee9c18ab Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 6 Nov 2026 13:33:21 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/cross-border/api/orderBudgets.ts | 4 +-- .../cross-border/businessOrders.test.mjs | 25 ++++++++++--- .../components/BusinessOrdersView.vue | 35 ++++++++++--------- .../components/OrderFundingStatus.vue | 2 +- .../cross-border/orderFundingStatus.test.mjs | 13 +++++++ src/modules/cross-border/procurement.test.mjs | 2 +- .../cross-border/transportDemandForm.test.mjs | 2 ++ .../cross-border/transportDemandForm.ts | 3 +- 8 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/modules/cross-border/api/orderBudgets.ts b/src/modules/cross-border/api/orderBudgets.ts index 16d8eab..37dbaf2 100644 --- a/src/modules/cross-border/api/orderBudgets.ts +++ b/src/modules/cross-border/api/orderBudgets.ts @@ -2,9 +2,9 @@ import { apiRequest } from '@/api/http' export interface BudgetResult { status: string; usable: boolean; amountCny: number | string; message: string; orderNo: string | null } const base = '/merchant/v1/last-leg/order-budgets' // 创建订单时后端在同一事务记录冻结请求,客户端只在详情核对状态和重试明确失败。 -export function readOrderFunding(kind: 'procurement' | 'trucking', orderNo: string) { +export function readOrderFunding(kind: 'procurement' | 'trucking' | 'looseboard', orderNo: string) { return apiRequest({ method: 'GET', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) }) } -export function retryOrderFunding(kind: 'procurement' | 'trucking', orderNo: string) { +export function retryOrderFunding(kind: 'procurement' | 'trucking' | 'looseboard', orderNo: string) { return apiRequest({ method: 'POST', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) + '/retry' }) } diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index c32f40f..5b5fc12 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -18,7 +18,7 @@ test('all current truck statuses display Chinese labels in lists and details', ( const { poolOrderStatusLabels } = runInNewContext(`${labelsCode}\n({ poolOrderStatusLabels })`) const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey(')) const compiled = ts.transpileModule(helpers, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText - const { statusLabel, displayCell } = runInNewContext(`${compiled}\n({ statusLabel, displayCell })`, { poolOrderStatusLabels }) + const { statusLabel, displayCell } = runInNewContext(`${compiled}\n({ statusLabel, displayCell })`, { poolOrderStatusLabels, props: { kind: 'trucking' } }) for (const status of ['PENDING_REVIEW', 'FUNDING_PENDING', 'FUNDING_FAILED', 'WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH', 'DISPATCHED', 'IN_TRANSIT', 'COMPLETED', 'REJECTED', 'CANCELLED']) { assert.match(statusLabel(status), /[\u4e00-\u9fff]/, status) assert.equal(displayCell({ status }, 'status'), statusLabel(status)) @@ -29,7 +29,7 @@ test('all current truck statuses display Chinese labels in lists and details', ( }) test('truck search excludes warehouse and dispatch preparation stages', () => { - const truckingOptions = view.split("options: props.kind === 'trucking' ? [")[1].split('] : [')[0] + const truckingOptions = view.split("options: props.kind === 'trucking' ? [")[1].split('] : Object.entries')[0] for (const status of ['WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH']) { assert.ok(!truckingOptions.includes(status), status) } @@ -149,7 +149,7 @@ function transportHarness(kind = 'trucking', failFirst = false, budgetCheck = as const script = view.match(/