diff --git a/Jenkinsfile b/Jenkinsfile index 877891f..19da01a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,15 +1,16 @@ pipeline { - agent { label 'java-test' } + agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" } options { timestamps() disableConcurrentBuilds() } stages { stage('检查测试发布参数') { + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { script { - if (env.JOB_BASE_NAME?.endsWith('-dev') || env.JOB_BASE_NAME?.endsWith('-prod')) { - error('当前仅支持 test,请使用测试环境 Job') + if (env.JOB_BASE_NAME?.endsWith('-dev')) { + error('此流水线仅支持 test 和本轮授权的 prod 灰度') } env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } @@ -23,6 +24,7 @@ pipeline { } } stage('构建并部署测试前端') { + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { sh '''set -eu docker compose -f docker-compose.test.yml build --pull=false @@ -31,8 +33,30 @@ pipeline { ''' } } + stage('发布临时生产灰度') { + when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } } + steps { + lock(resource: 'firstleg-production-client-web-deploy') { + withCredentials([ + usernamePassword(credentialsId: 'gitea-registry-token', usernameVariable: 'BUILD_REGISTRY_USER', passwordVariable: 'BUILD_REGISTRY_PASSWORD'), + usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), + sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), + string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), + file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + ]) { + sh 'sh scripts/deploy-production.sh' + } + } + } + } } post { - failure { sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' } + failure { + script { + if (!env.JOB_BASE_NAME?.endsWith('-prod')) { + sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' + } + } + } } } diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..e7f339d --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,22 @@ +name: firstleg-prod-client-web +services: + client-web: + image: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}" + container_name: firstleg-prod-client-web + restart: unless-stopped + ports: + - "127.0.0.1:${HOST_PORT:-29012}:80" + environment: + API_UPSTREAM: "http://gateway:9000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s + networks: + - backend +networks: + backend: + external: true + name: firstleg-production-network diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 0000000..ebcb234 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu +set +x +: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}" +: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}" +: "${TARGET_SSH_KEY:?}" +: "${TARGET_SSH_USER:?}" +: "${TARGET_SSH_HOST:?}" +: "${TARGET_SSH_KNOWN_HOSTS:?}" +: "${BUILD_REGISTRY_USER:?}" +: "${BUILD_REGISTRY_PASSWORD:?}" +: "${REGISTRY_USER:?}" +: "${REGISTRY_PASSWORD:?}" +case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac +# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters. +printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$' +printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$' +git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" +IMAGE_TAG=$(git rev-parse --short=12 HEAD) +registry=${PRODUCTION_IMAGE_REPOSITORY%%/*} +image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG" +TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST" +remote_dir=/data/firstleg/deploy/gray-client-web +run_ssh() { + ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@" +} +run_scp() { + scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@" +} +export DOCKER_CONFIG +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}" \ + --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" +run_ssh install -d -m 750 "$remote_dir" +run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml" +# The remote env file contains only image coordinates and public web origin, never credentials. +printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \ + "$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'" +# Registry credentials exist only in an isolated temporary Docker config, removed after pull. +printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$' +run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh" +printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image" +run_ssh sh -s -- "$remote_dir" <<'REMOTE' +set -eu +cd "$1" +docker network inspect firstleg-production-network >/dev/null +docker compose --env-file release.env -f compose.yml config --quiet +docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120 +docker compose --env-file release.env -f compose.yml exec -T client-web wget -qO- http://127.0.0.1/healthz +REMOTE diff --git a/scripts/pull-production-image.sh b/scripts/pull-production-image.sh new file mode 100644 index 0000000..91562c3 --- /dev/null +++ b/scripts/pull-production-image.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +set +x +export DOCKER_CONFIG +DOCKER_CONFIG=$(mktemp -d) +cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } +trap cleanup EXIT HUP INT TERM +docker login -u "$1" --password-stdin "$2" +docker pull "$3" diff --git a/shared/src/index.ts b/shared/src/index.ts index a1a89da..bbee384 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -92,6 +92,7 @@ export const FIRST_LEG_ORDER_STATUSES = [ { code: 'LOADED', label: '装柜完成' }, { code: 'DELIVERED', label: '派送完成' }, { code: 'COMPLETED', label: '已完成' }, + { code: 'CANCELLED', label: '已取消' }, ] as const export type FirstLegOrderStatus = (typeof FIRST_LEG_ORDER_STATUSES)[number]['code'] diff --git a/src/api/http.ts b/src/api/http.ts index eb0b66e..9b9b4be 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,6 +1,6 @@ import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import { parseApiJsonResponse } from '@chongchong/shared' -import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' +import { accessTokenIdentity, clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' import { ApiError, isApiEnvelope, isApiFailure, type ApiEnvelope } from '@/api/types' import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh' @@ -13,6 +13,8 @@ const transport = axios.create({ interface RetryableRequest extends InternalAxiosRequestConfig { _retriedAfterRefresh?: boolean + _sessionIdentity?: string | null + _logoutGeneration?: number } function unwrap(envelope: ApiEnvelope) { @@ -51,9 +53,12 @@ function mapError(error: unknown) { return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status) } +let refreshExchange: { identity: string; refreshToken: string; promise: Promise } | null = null +let logoutGeneration = 0 let refreshPromise: Promise | null = null +let refreshIdentity: string | null = null let redirectingToLogin = false -const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange'] +const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout', '/auth/impersonations/exchange'] function isPublicAuthRequest(url?: string) { return publicAuthPaths.some((path) => url?.endsWith(path)) @@ -70,29 +75,46 @@ function redirectToLoginAfterSessionExpiry() { window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`) } +function requireCurrentIdentity(identity: string | null | undefined, accessToken = getAccessToken()) { + if (!identity || accessTokenIdentity(accessToken) !== identity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') +} + export async function refreshAuthenticatedSession() { + const observedIdentity = accessTokenIdentity(getAccessToken()) + requireCurrentIdentity(observedIdentity) const observedRefreshToken = getRefreshToken() if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) if (!refreshPromise) { + refreshIdentity = observedIdentity refreshPromise = coordinateCrossTabRefresh({ lockName: 'cc-client-auth-refresh', observedRefreshToken, currentRefreshToken: getRefreshToken, reuseCurrentSession: async () => { + requireCurrentIdentity(observedIdentity) const currentAccessToken = getAccessToken() if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) return currentAccessToken }, refresh: async (currentRefreshToken) => { - const response = await transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }) - const tokens = unwrap(response.data) + requireCurrentIdentity(observedIdentity) + const exchange = { identity: observedIdentity!, refreshToken: currentRefreshToken, promise: transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }).then(response => unwrap(response.data)) } + refreshExchange = exchange + let tokens: TokenPair + try { tokens = await exchange.promise } finally { if (refreshExchange === exchange) refreshExchange = null } + requireCurrentIdentity(observedIdentity) + requireCurrentIdentity(observedIdentity, tokens.accessToken) if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401) + if (getRefreshToken() !== currentRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED') persistTokenPair(tokens) + if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('cc-client-session-updated')) return tokens.accessToken }, }) .catch((error) => { + requireCurrentIdentity(observedIdentity) if (error instanceof CrossTabRefreshTimeoutError) { + if (getRefreshToken() && getRefreshToken() !== observedRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED') const expiredError = new ApiError(error.message, 'A0401', 401) clearSessionTokens() window.dispatchEvent(new CustomEvent('cc-client-session-expired')) @@ -103,33 +125,60 @@ export async function refreshAuthenticatedSession() { }) .finally(() => { refreshPromise = null + refreshIdentity = null }) } - return refreshPromise + if (refreshIdentity !== observedIdentity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') + const refreshed = await refreshPromise + requireCurrentIdentity(observedIdentity) + return refreshed } transport.interceptors.request.use((config) => { const token = getAccessToken() - if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}` + const request = config as RetryableRequest + if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token) + if (token && !isPublicAuthRequest(config.url)) { + if (!request._retriedAfterRefresh) { + request._sessionIdentity = accessTokenIdentity(token) + request._logoutGeneration = logoutGeneration + } + config.headers.Authorization = `Bearer ${token}` + } return config }) transport.interceptors.response.use( - (response) => response, + (response) => { + const request = response.config as RetryableRequest + if (request._sessionIdentity) { + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') + requireCurrentIdentity(request._sessionIdentity) + } + return response + }, async (error: AxiosError) => { const request = error.config as RetryableRequest | undefined if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) { throw mapError(error) } + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') + requireCurrentIdentity(request._sessionIdentity) request._retriedAfterRefresh = true + const refreshTokenBeforeRetry = getRefreshToken() try { const accessToken = await refreshAuthenticatedSession() + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') + requireCurrentIdentity(request._sessionIdentity) request.headers.Authorization = `Bearer ${accessToken}` return await transport.request(request) } catch (refreshError) { + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') + requireCurrentIdentity(request._sessionIdentity) const mappedError = mapError(refreshError) if (mappedError.status === 401 || mappedError.code === 'A0401') { + if (getRefreshToken() && getRefreshToken() !== refreshTokenBeforeRetry) throw mappedError clearSessionTokens() window.dispatchEvent(new CustomEvent('cc-client-session-expired')) redirectToLoginAfterSessionExpiry() @@ -139,6 +188,33 @@ transport.interceptors.response.use( }, ) +// Captured revocation never persists credentials or restores local login state. +export async function revokeCapturedSession(accessToken: string, refreshToken: string) { + logoutGeneration++ + const identity = accessTokenIdentity(accessToken) + if (!identity || !refreshToken) throw new ApiError('服务器会话注销未确认', 'SESSION_CHANGED') + const pending = refreshExchange?.identity === identity && refreshExchange.refreshToken === refreshToken ? refreshExchange.promise : null + const revoke = (pair: Pick) => apiRequest({ + method: 'POST', url: '/auth/logout', data: { refreshToken: pair.refreshToken }, + headers: { Authorization: `Bearer ${pair.accessToken}` }, + }) + const validate = (pair: TokenPair) => { + if (accessTokenIdentity(pair.accessToken) !== identity || !pair.refreshToken) throw new ApiError('刷新身份不一致,服务器会话注销未确认', 'SESSION_CHANGED') + return pair + } + // An already sent refresh can rotate the pair. Consume its actual result + // without issuing a competing refresh or restoring it to local storage. + if (pending) { + await revoke(validate(await pending)) + return + } + try { await revoke({ accessToken, refreshToken }) } catch (error) { + if (!(error instanceof ApiError) || error.status !== 401) throw error + const pair = await apiRequest({ method: 'POST', url: '/auth/refresh', data: { refreshToken } }) + await revoke(validate(pair)) + } +} + export async function apiRequest(config: AxiosRequestConfig) { try { const response = await transport.request>(config) diff --git a/src/api/session.test.mjs b/src/api/session.test.mjs index ce48286..b305710 100644 --- a/src/api/session.test.mjs +++ b/src/api/session.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + accessTokenIdentity, ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, REMEMBERED_LOGIN_ID_KEY, @@ -56,3 +57,34 @@ test('remembers only the login id and keeps it when session tokens are cleared', persistRememberedLoginId('merchant-user', false) assert.equal(getRememberedLoginId(), '') }) + +test('normal token rotation never removes either shared token between writes', () => { + installStorage() + localStorage.setItem(ACCESS_TOKEN_KEY, 'old-access') + localStorage.setItem(REFRESH_TOKEN_KEY, 'old-refresh') + const removed = [] + const remove = localStorage.removeItem + localStorage.removeItem = key => { removed.push(key); remove(key) } + persistTokenPair({ accessToken: 'new-access', refreshToken: 'new-refresh' }) + assert.ok(!removed.includes(ACCESS_TOKEN_KEY)) + assert.ok(!removed.includes(REFRESH_TOKEN_KEY)) +}) + +function rawIdentity(overrides = '') { + return `test.${Buffer.from(`{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"${overrides}}`).toString('base64url')}.test` +} +test('identity preserves low digits of numeric 19 digit user customer and membership',()=>{ + const original=rawIdentity();const json=Buffer.from(original.split('.')[1],'base64url').toString(); + for(const field of ['user_id','tenant_id','membership_id']){ + const changed=json.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`) + assert.notEqual(accessTokenIdentity(original),accessTokenIdentity(`test.${Buffer.from(changed).toString('base64url')}.test`)) + } +}) +test('identity string and numeric integers are equivalent without changing other strings',()=>{ + const a=rawIdentity();const raw=Buffer.from(a.split('.')[1],'base64url').toString(); + const b=`test.${Buffer.from(raw.replaceAll(':2107003446494334978',':"2107003446494334978"')).toString('base64url')}.test` + assert.equal(accessTokenIdentity(a),accessTokenIdentity(b)) + const decorated=rawIdentity(',"username":'+JSON.stringify('number 2107003446494334979 and "tenant_id":2107003446494334979')+',"note":"1234567890123456789"') + assert.equal(accessTokenIdentity(a),accessTokenIdentity(decorated)) + assert.ok(accessTokenIdentity(a).includes('2107003446494334978')) +}) diff --git a/src/api/session.ts b/src/api/session.ts index dbdcdd1..fb639ab 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -19,9 +19,38 @@ export function getRefreshToken() { return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) || '' } +// Only binds a retry to its original identity; never grants permissions. Expired +// access tokens remain readable here because they are the normal refresh trigger. +export function accessTokenIdentity(token: string): string | null { + try { + const encoded = token.split('.')[1] + if (!encoded) return null + const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/') + const bytes = Uint8Array.from(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')), character => character.charCodeAt(0)) + const json = new TextDecoder().decode(bytes) + // Match whole JSON strings first so digits inside arbitrary string values + // are never rewritten. Preserve integer identity claims before JSON.parse + // can round them; number/string representations then share one identity. + const exactIdentityJson = json.replace(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (part, offset: number) => { + if (!/^-?\d+$/.test(part)) return part + const key = json.slice(0, offset).match(/"(user_id|tenant_id|membership_id|operator_tenant_id)"\s*:\s*$/) + return key ? JSON.stringify(part) : part + }) + const value = JSON.parse(exactIdentityJson) + if (!value?.user_id || !value.tenant_id || !value.membership_id || value.app_code !== 'MERCHANT') return null + return JSON.stringify([value.user_id, value.tenant_id, value.membership_id, value.operator_tenant_id, value.app_code, value.client_type].map(item => item == null ? null : String(item))) + } catch { + return null + } +} + export function persistTokenPair(tokens: TokenPair) { - clearSessionTokens() + // Replace the pair without publishing a transient logout to other tabs. + sessionStorage.removeItem(ACCESS_TOKEN_KEY) + sessionStorage.removeItem(REFRESH_TOKEN_KEY) + sessionStorage.removeItem('cc-client-token') + localStorage.removeItem('cc-client-token') localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) } diff --git a/src/api/unifiedHttp.test.mjs b/src/api/unifiedHttp.test.mjs index ecc5ba3..b051b7f 100644 --- a/src/api/unifiedHttp.test.mjs +++ b/src/api/unifiedHttp.test.mjs @@ -18,13 +18,21 @@ function compile(name, dependencies = {}) { return exports } +function access(user, tenant = 1, membership = 1, nonce = 'original') { + return `test.${Buffer.from(JSON.stringify({ user_id: user, tenant_id: tenant, membership_id: membership, app_code: 'MERCHANT', nonce })).toString('base64url')}.test` +} +const oldAccess = access(1) +const newAccess = access(1, 1, 1, 'rotated') +const otherAccess = access(2, 2, 2) + function harness(adapter) { - let token = 'old-access' + let token = oldAccess let refreshToken = 'old-refresh' const http = compile('./http.ts', { axios: { ...axios, create: (config) => axios.create({ ...config, adapter }) }, '@chongchong/shared': { parseApiJsonResponse: (data) => data }, '@/api/session': { + accessTokenIdentity: compile('./session.ts').accessTokenIdentity, getAccessToken: () => token, getRefreshToken: () => refreshToken, persistTokenPair: (tokens) => { token = tokens.accessToken; refreshToken = tokens.refreshToken }, clearSessionTokens: () => { token = ''; refreshToken = '' }, @@ -33,7 +41,7 @@ function harness(adapter) { '@/api/types': compile('./types.ts'), '@/api/crossTabRefresh': compile('./crossTabRefresh.ts'), }) - return http + return { ...http, setSession: (access, refresh) => { token = access; refreshToken = refresh }, session: () => ({ token, refreshToken }) } } test('order and lastleg 401s share one refresh and replay the unified access token', async () => { @@ -44,9 +52,9 @@ test('order and lastleg 401s share one refresh and replay the unified access tok if (config.url === '/auth/refresh') { refreshes++ await new Promise((resolve) => setTimeout(resolve, 10)) - return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: 'new-access', refreshToken: 'new-refresh' } } } + return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: newAccess, refreshToken: 'new-refresh' } } } } - if (config.headers.Authorization === 'Bearer old-access') { + if (config.headers.Authorization === `Bearer ${oldAccess}`) { throw new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } }) } return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: config.url } } @@ -56,7 +64,7 @@ test('order and lastleg 401s share one refresh and replay the unified access tok assert.equal(refreshes, 1) assert.ok(calls.every((call) => call.base === '/api')) assert.equal(calls.find((call) => call.url === '/auth/refresh').token, undefined) - assert.equal(calls.filter((call) => call.token === 'Bearer new-access').length, 2) + assert.equal(calls.filter((call) => call.token === `Bearer ${newAccess}`).length, 2) }) test('successful order submit with explicit null data is accepted', async () => { @@ -76,3 +84,139 @@ test('data-less security failures preserve diagnostics and malformed success is }) } }) + +test('late refresh response cannot overwrite a session changed in another tab', async () => { + let complete, started + const ready = new Promise(resolve => { started = resolve }) + const response = new Promise(resolve => { complete = resolve }) + const http = harness(async config => { started(); await response; return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: oldAccess, refreshToken: 'stale-refresh' } } } }) + const pending = http.refreshAuthenticatedSession() + await ready + http.setSession(otherAccess, 'other-refresh') + complete() + await pending.catch(error => assert.equal(error.code, 'SESSION_CHANGED')) + assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'other-refresh' }) +}) + +function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } } + +function unauthorized(config) { + return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } }) +} + +test('late A write 401 after user, customer or membership switch never refreshes or replays under B', async () => { + for (const next of [access(2), access(1, 2), access(1, 1, 2)]) { + const sent = deferred(), release = deferred(), calls = [] + const http = harness(async config => { + calls.push(config.url); sent.resolve(); await release.promise + throw unauthorized(config) + }) + const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit', data: { expectedVersion: 1 } }) + const rejected = assert.rejects(pending, error => error.code === 'SESSION_CHANGED') + await sent.promise; http.setSession(next, 'refresh-b'); release.resolve(); await rejected + assert.deepEqual(calls, ['/merchant/v1/orders/first-leg/one/submit']) + assert.deepEqual(http.session(), { token: next, refreshToken: 'refresh-b' }) + } +}) + +test('switch during in-flight refresh cannot reuse B token to replay an A write', async () => { + const refreshing = deferred(), release = deferred(), calls = [] + const http = harness(async config => { + calls.push(config.url) + if (config.url === '/auth/refresh') { + refreshing.resolve(); await release.promise + return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: newAccess, refreshToken: 'new-refresh' } } } + } + throw unauthorized(config) + }) + const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit' }) + const rejected = assert.rejects(pending, error => error.code === 'SESSION_CHANGED') + await refreshing.promise; http.setSession(otherAccess, 'refresh-b'); release.resolve(); await rejected + assert.deepEqual(calls, ['/merchant/v1/orders/first-leg/one/submit', '/auth/refresh']) + assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'refresh-b' }) +}) + +test('refresh lock wait reuses same-identity rotation but rejects changed-identity reuse', async t => { + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + t.after(() => { if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); else delete globalThis.navigator }) + for (const next of [newAccess, otherAccess]) { + const waiting = deferred(), release = deferred(), calls = [] + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks: { request: async (_name, _options, run) => { + waiting.resolve(); await release.promise; return run() + } } } }) + const http = harness(async config => { + calls.push({ url: config.url, authorization: config.headers.Authorization }) + if (config.headers.Authorization === `Bearer ${oldAccess}`) throw unauthorized(config) + return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: null } } + }) + const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit' }) + const outcome = next === otherAccess ? assert.rejects(pending, error => error.code === 'SESSION_CHANGED') : pending + await waiting.promise; http.setSession(next, 'rotated-refresh'); release.resolve(); await outcome + assert.equal(calls.some(call => call.url === '/auth/refresh'), false) + assert.equal(calls.length, next === otherAccess ? 1 : 2) + assert.deepEqual(http.session(), { token: next, refreshToken: 'rotated-refresh' }) + } +}) + +function authApi(http) { return compile('../modules/auth/api/auth.ts', { '@/api/http': http }) } +function ok(config, data = null) { return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data } } } +test('logout after immediate local clear revokes captured Bearer and Refresh', async () => { + const calls=[]; const http=harness(async c=>{calls.push(c);return ok(c)}) + http.setSession('', '');await authApi(http).logoutMerchant(oldAccess,'old-refresh') + assert.equal(calls.length,1);assert.equal(calls[0].headers.Authorization,`Bearer ${oldAccess}`) + assert.deepEqual(JSON.parse(calls[0].data),{refreshToken:'old-refresh'});assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) +test('only explicit logout401 refreshes once and revokes same-identity replacement without storing it', async () => { + const calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'});if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)}) + http.setSession('','');await authApi(http).logoutMerchant(oldAccess,'old-refresh') + assert.deepEqual(calls.map(x=>x.url),['/auth/logout','/auth/refresh','/auth/logout']);assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[2].data).refreshToken,'new-refresh');assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) +test('unknown logout failure never refreshes; changed identity cannot be revoked as replacement', async () => { + for(const mode of ['network','wrongIdentity','second401']){const calls=[];const http=harness(async c=>{calls.push(c);if(mode==='network')throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c);if(c.url==='/auth/refresh')return ok(c,{accessToken:mode==='wrongIdentity'?otherAccess:newAccess,refreshToken:'replacement'});throw unauthorized(c)}) + http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(oldAccess,'old-refresh'));assert.equal(calls.length,mode==='network'?1:mode==='wrongIdentity'?2:3);assert.deepEqual(http.session(),{token:'',refreshToken:''})} +}) +test('logout consumes already inflight refresh once while old business caller cannot revive session',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}return ok(c)}) + const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing,e=>e.code==='SESSION_CHANGED');await started.promise + const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');release.resolve();await leaving;await rejected + assert.deepEqual(calls.map(x=>x.url),['/auth/refresh','/auth/logout']);assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) +test('late successful business response after logout rejects even after same-identity new login',async()=>{ + const started=deferred(),release=deferred();const http=harness(async c=>{if(c.url==='/merchant/v1/orders'){started.resolve();await release.promise}return ok(c,'old-data')}) + const pending=http.apiRequest({url:'/merchant/v1/orders'});const rejected=assert.rejects(pending,e=>e.code==='SESSION_CHANGED');await started.promise + await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected;assert.equal(http.session().refreshToken,'new-login-refresh') +}) + +test('inflight refresh unknown outcome stops logout without competing retry and preserves later login',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);started.resolve();await release.promise;throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c)}) + const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise + const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');const failure=assert.rejects(leaving,e=>e.code==='NETWORK_ERROR');http.setSession(otherAccess,'other-refresh');release.resolve();await failure;await rejected + assert.equal(calls.length,1);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'}) +}) +test('logout401 refresh and replacement revocation cannot overwrite later login',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)}) + const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');await started.promise;http.setSession(otherAccess,'other-refresh');release.resolve();await leaving + assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'}) +}) +test('late old business401 after logout does not refresh or replay under same-identity new login',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/merchant/v1/orders/first-leg/one/submit'){started.resolve();await release.promise;throw unauthorized(c)}return ok(c)}) + const request=http.apiRequest({method:'POST',url:'/merchant/v1/orders/first-leg/one/submit'});const rejected=assert.rejects(request,e=>e.code==='SESSION_CHANGED');await started.promise;await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected + assert.deepEqual(calls.map(x=>x.url),['/merchant/v1/orders/first-leg/one/submit','/auth/logout']);assert.equal(http.session().refreshToken,'new-login-refresh') +}) + +test('same identity new pair logout does not adopt another session inflight refresh',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'old-session-rotated'})}return ok(c)}) + const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise;http.setSession(newAccess,'new-login-refresh') + const leaving=authApi(http).logoutMerchant(newAccess,'new-login-refresh');http.setSession('','');await leaving + assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[1].data).refreshToken,'new-login-refresh');release.resolve();await rejected;assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) + +test('logout rejects refreshed 19 digit identity differing only in low digit',async()=>{ + const raw='{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"}' + const original=`test.${Buffer.from(raw).toString('base64url')}.test` + for(const field of ['user_id','tenant_id','membership_id']){ + const changed=raw.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`),calls=[] + const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:`test.${Buffer.from(changed).toString('base64url')}.test`,refreshToken:'replacement'});throw unauthorized(c)}) + http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(original,'original-refresh'),e=>e.code==='SESSION_CHANGED');assert.equal(calls.length,2) + } +}) diff --git a/src/components/common/DataTable.test.mjs b/src/components/common/DataTable.test.mjs index 4466aa9..47d7d96 100644 --- a/src/components/common/DataTable.test.mjs +++ b/src/components/common/DataTable.test.mjs @@ -1,9 +1,76 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { parse, compileScript } from 'vue/compiler-sfc' +import ts from 'typescript' +import * as vue from 'vue' const source = readFileSync(new URL('./DataTable.vue', import.meta.url), 'utf8') +function paginationHarness(t) { + const descriptor = parse(source).descriptor + const compiled = compileScript(descriptor, { id: 'data-table-pagination-test' }) + const code = ts.transpileModule(compiled.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText + const exports = {} + const deps = { vue, '@lucide/vue': { ChevronLeft: {}, ChevronRight: {} }, './tableActions': {} } + new Function('require', 'exports', code)(id => { assert.ok(id in deps, id); return deps[id] }, exports) + const component = exports.default + component.render = () => null + const state = vue.reactive({ page: 5, total: 5542, loading: false, error: false }) + const emitted = [] + const renderer = vue.createRenderer({ + createElement: tag => ({ tag }), createText: text => ({ text }), createComment: text => ({ text }), + insert() {}, remove() {}, setElementText() {}, setText() {}, patchProp() {}, + parentNode: () => null, nextSibling: () => null, + }) + const app = renderer.createApp({ render: () => vue.h(component, { + data: [], columns: [], page: state.page, pageSize: 50, total: state.total, loading: state.loading, + 'onUpdate:page': value => { emitted.push(value); state.page = value }, + }, state.error ? { error: () => vue.h('div', 'Request failed') } : {}) }) + app.mount({}) + t.after(() => app.unmount()) + return { state, emitted } +} + +test('server-page loading must not reset the requested page when total temporarily clears', async t => { + const { state, emitted } = paginationHarness(t) + state.loading = true; state.total = 0 + await vue.nextTick() + assert.equal(state.page, 5) + assert.deepEqual(emitted, []) + state.total = 5542; state.loading = false + await vue.nextTick() + assert.equal(state.page, 5) + assert.deepEqual(emitted, []) +}) + +test('failed server page retains its page for explicit retry and emits no automatic fallback request', async t => { + const { state, emitted } = paginationHarness(t) + state.loading = true; state.total = 0 + await vue.nextTick() + state.error = true; state.loading = false + await vue.nextTick() + assert.equal(state.page, 5) + assert.deepEqual(emitted, []) + state.error = false; state.loading = true + await vue.nextTick() + state.total = 5542; state.loading = false + await vue.nextTick() + assert.equal(state.page, 5) + assert.deepEqual(emitted, []) +}) + +test('settled successful totals still clamp out-of-range and empty pages', async t => { + const { state, emitted } = paginationHarness(t) + state.total = 120 + await vue.nextTick() + assert.equal(state.page, 3) + state.total = 0 + await vue.nextTick() + assert.equal(state.page, 1) + assert.deepEqual(emitted, [3, 1]) +}) + test('keeps action-only table toolbars on the left', () => { assert.match(source, /'data-table__toolbar--actions-only': !\$slots\.toolbar/) assert.match(source, /v-if="\$slots\.toolbar" class="data-table__toolbar-heading"/) diff --git a/src/components/common/DataTable.vue b/src/components/common/DataTable.vue index 3ef382c..cc318d5 100644 --- a/src/components/common/DataTable.vue +++ b/src/components/common/DataTable.vue @@ -88,7 +88,9 @@ watch([pageSize, normalizedPageSizes], ([value, sizes]) => { page.value = 1 }, { immediate: true }) -watch([page, pageCount], ([value, count]) => { +watch([page, pageCount, () => props.loading, () => Boolean(slots.error)], ([value, count, loading, hasError]) => { + // A cleared total is not an authoritative page count while loading or showing a request error. + if (loading || hasError) return const nextValue = normalizePage(value, count) if (value !== nextValue) page.value = nextValue }, { immediate: true }) diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index 67bee6b..83548bf 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -26,13 +26,12 @@ test('test deployment requires an explicit shared network, Gateway and version', assert.match(compose, /external: true/) assert.match(compose, /127\.0\.0\.1:/) assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/) - assert.match(jenkins, /label 'java-test'/) + assert.match(jenkins, /'java-builder' : 'java-test'/) assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/) assert.match(jenkins, /docker network inspect/) - assert.doesNotMatch(jenkins, /docker-compose\.(production|development)|deploy-production|sshUserPrivateKey/) - for (const file of ['docker-compose.production.yml', 'docker-compose.development.yml', 'scripts/deploy-production.sh']) { - assert.equal(existsSync(new URL(file, root)), false) - } + assert.match(jenkins, /scripts\/deploy-production\.sh/) + assert.match(jenkins, /firstleg-production-ssh/) + }) test('application shell is revalidated and missing chunks remain 404', () => { @@ -52,3 +51,13 @@ test('container build excludes local environment values and waits for Nginx heal assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/) assert.match(jenkins, /up -d --wait --wait-timeout 120/) }) + +test('gray production is isolated from TEST and routes to the single Gateway', () => { + const production = readFileSync(new URL('docker-compose.production.yml', root), 'utf8') + assert.match(production, /name: firstleg-production-network/) + assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/) + assert.match(production, /127\.0\.0\.1:/) + assert.match(production, /healthcheck:/) + assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/) + assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/) +}) diff --git a/src/layouts/AppLayout.test.mjs b/src/layouts/AppLayout.test.mjs index 35e8d25..272ee3e 100644 --- a/src/layouts/AppLayout.test.mjs +++ b/src/layouts/AppLayout.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import ts from 'typescript' +import * as vue from 'vue' const layoutSource = readFileSync(new URL('./AppLayout.vue', import.meta.url), 'utf8') const shellStyles = readFileSync(new URL('../styles/shell.css', import.meta.url), 'utf8') @@ -108,7 +110,7 @@ test('source contract: locks body scrolling while open and restores it on close }) test('source contract: handles confirmation cancellation but rethrows unexpected logout errors', () => { - assert.match(layoutSource, /async function handleLogout\(\) \{\s*try \{/) + assert.match(layoutSource, /async function handleLogout\(\) \{\s*const logoutToken = auth.token\s*try \{/) assert.match(layoutSource, /\} catch \(error\) \{\s*if \(error === 'cancel' \|\| error === 'close'\) return\s*throw error\s*\}/) }) @@ -148,3 +150,49 @@ test('source contract: routes denied pages to an explicit 403 state', () => { assert.match(routerSource, /!auth\.hasPermission\(permission\)/) assert.match(routerSource, /getFirstAccessiblePath\(auth\.permissionCodes\)/) }) + +function logoutHarness(t) { + const script = layoutSource.match(/ @@ -413,6 +420,24 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
{{ field[0] }}
{{ text(field[1]) }}
+
+

承运安排

{{ fleetArrangement.readOnlyReason }}

+
+
预订号
{{ fleetArrangement.bookingNo }}
+
计划发车
{{ dateTime(fleetArrangement.plannedDepartureAt) }}
+
起运仓
{{ text(fleetArrangement.originWarehouseName) }}
+
目的地
{{ text(fleetArrangement.destinationLabel) }}
+
交货方式
{{ fleetArrangement.deliveryMode === 'SELF_DELIVERY' ? '自行送至起运仓' : '线下安排提货至起运仓' }}
+
履约进度
{{ poolOrderStatusLabels[fleetArrangement.fulfillmentStage] || '待核对履约进度' }}
+
更新时间
{{ dateTime(fleetArrangement.stageOccurredAt) }}
+
约定运费(非实收)
{{ money(fleetArrangement.agreedFreightAmount, fleetArrangement.currency) }}
+
来货单号
{{ text(fleetArrangement.incomingNo) }}
+
派送单号
{{ text(fleetArrangement.deliveryNo) }}
+
已签 / 未签 / 退回箱数
{{ fleetArrangement.signedPackages ?? '—' }} / {{ fleetArrangement.unsignedPackages ?? '—' }} / {{ fleetArrangement.returnedPackages ?? '—' }}
+
交货说明
{{ text(fleetArrangement.deliveryInstructions) }}
+
履约说明
{{ fleetArrangement.exceptionSummary }}
+
+
@@ -472,6 +497,8 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat .detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); } .detail-grid div.is-wide { grid-column: span 2; } .detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; } +.arrangement-grid div { grid-template-columns: 124px minmax(0, 1fr); } +.arrangement-grid dt { white-space: normal; overflow-wrap: anywhere; } .detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 600; line-height: 1.4; } .form-card :deep(.el-form) { max-width: 860px; } .form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; } diff --git a/src/modules/cross-border/fleetArrangement.ts b/src/modules/cross-border/fleetArrangement.ts new file mode 100644 index 0000000..4ad6d1a --- /dev/null +++ b/src/modules/cross-border/fleetArrangement.ts @@ -0,0 +1,28 @@ +export interface FleetArrangement { + sourceType: 'FLEET_CAPACITY_OFFER' + businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK' + businessOrderNo: string + bookingNo: string + tripNo: string + plannedDepartureAt: string | null + originWarehouseName: string + destinationLabel: string + deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE' + deliveryInstructions: string | null + fulfillmentStage: string + stageOccurredAt: string | null + agreedFreightAmount: number | null + currency: string | null + incomingNo: string | null + dispatchNo: string | null + deliveryNo: string | null + signedPackages: number | null + unsignedPackages: number | null + returnedPackages: number | null + exceptionSummary: string | null + readOnlyReason: string +} +export const poolOrderStatusLabels: Record = { + WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', + PENDING_DISPATCH: '待派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', +} diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index 1ae92a3..fd7885e 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -566,14 +566,19 @@ export function createMerchantFirstLegOrderBatch(items: MerchantBatchDraftItem[] }) } -export function updateMerchantFirstLegOrder(id: number | string, payload: FirstLegOrderSaveRequest) { - return apiRequest({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload }) +export function updateMerchantFirstLegOrder(id: number | string, payload: { expectedVersion: number; draft: FirstLegOrderSaveRequest }, idempotencyKey: string) { + return apiRequest({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload, headers: { 'Idempotency-Key': idempotencyKey } }) } -export function submitMerchantFirstLegOrder(id: number | string, idempotencyKey: string) { +export function cancelMerchantFirstLegOrder(id: number | string, payload: { expectedVersion: number; reason: string }, idempotencyKey: string) { + return apiRequest({ method: 'POST', url: `/merchant/v1/orders/first-leg/${id}/cancel`, data: payload, headers: { 'Idempotency-Key': idempotencyKey } }) +} + +export function submitMerchantFirstLegOrder(id: number | string, payload: { expectedVersion: number }, idempotencyKey: string) { return apiRequest({ method: 'POST', url: `/merchant/v1/orders/first-leg/${id}/submit`, + data: payload, headers: { 'Idempotency-Key': idempotencyKey }, }) } diff --git a/src/modules/first-leg/api/unifiedOrders.test.mjs b/src/modules/first-leg/api/unifiedOrders.test.mjs index 9e1ee43..2863941 100644 --- a/src/modules/first-leg/api/unifiedOrders.test.mjs +++ b/src/modules/first-leg/api/unifiedOrders.test.mjs @@ -1,9 +1,28 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import ts from 'typescript' const source = readFileSync(new URL('./unifiedOrders.ts', import.meta.url), 'utf8') +test('forwards optional tax mode and pagination through the current GET endpoint', async () => { + const calls = [] + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText + const api = {} + new Function('require', 'exports', code)(id => { + assert.equal(id, '@/api/http') + return { apiRequest: async config => { calls.push(config); return { records: [] } } } + }, api) + await api.getMerchantUnifiedOrders({ page: 2, size: 200, taxMode: 'TAX_REFUND' }) + await api.getMerchantUnifiedOrders({ taxMode: 'BUY_DECLARATION' }) + await api.getMerchantUnifiedOrders() + assert.deepEqual(calls, [ + { method: 'GET', url: '/merchant/v1/orders', params: { page: 2, size: 200, taxMode: 'TAX_REFUND' } }, + { method: 'GET', url: '/merchant/v1/orders', params: { page: 1, size: 200, taxMode: 'BUY_DECLARATION' } }, + { method: 'GET', url: '/merchant/v1/orders', params: { page: 1, size: 200 } }, + ]) +}) + test('keeps the merchant unified-order boundary and one-click palletization status', () => { assert.match(source, /const unifiedOrderBaseUrl = '\/merchant\/v1\/orders'/) assert.match(source, /ApiUnifiedOrderMainStatus[\s\S]*?'WAIT_PALLETIZE'/) diff --git a/src/modules/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts index 07464d0..fdde238 100644 --- a/src/modules/first-leg/api/unifiedOrders.ts +++ b/src/modules/first-leg/api/unifiedOrders.ts @@ -1,4 +1,4 @@ -import { ApiError, apiRequest } from '@/api/http' +import { apiRequest } from '@/api/http' import type { ApiContainerMode, ApiDestinationType, ApiFirstLegStatus, ApiPickupMethod, ApiTaxMode, MerchantFirstLegOrderDetail, PageResult } from '@/modules/first-leg/api/firstLeg' export type ApiUnifiedOrderMainStatus = @@ -60,11 +60,12 @@ export interface MerchantUnifiedTransportSummary { export interface MerchantUnifiedOrderRow { orderId: number | string + orderVersion: number tenantId: number | string orderNo: string | null fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | null lxCode?: string | null - mainStatus: ApiUnifiedOrderMainStatus + mainStatus: ApiUnifiedOrderMainStatus | 'DRAFT' | 'CANCELLED' nextAction: string | null customerFeeConfirmationRequired: boolean internalOrderStatus: ApiFirstLegStatus | null @@ -132,7 +133,25 @@ export interface MerchantUnifiedCustomNode { attachmentIds: Array } +export interface MerchantTransportNode { + standardNode: string + nodeSequence: number + providerCode: string | null + estimatedAt: string | null + actualAt: string | null + locationCode: string | null + locationName: string | null +} + +export interface MerchantTransportFragment { + inboundCode: string | null + cabinetNo: string | null + containerNo: string | null + nodes: MerchantTransportNode[] +} + export interface MerchantUnifiedOrderDetail { + transportFragments: MerchantTransportFragment[] summary: MerchantUnifiedOrderRow order: MerchantFirstLegOrderDetail timeline: MerchantUnifiedTimelineEvent[] @@ -141,6 +160,8 @@ export interface MerchantUnifiedOrderDetail { } export interface MerchantUnifiedOrderQuery { + taxMode?: ApiTaxMode + loaded?: boolean keyword?: string fbaCode?: string orderNo?: string @@ -156,6 +177,31 @@ export interface MerchantUnifiedOrderQuery { const unifiedOrderBaseUrl = '/merchant/v1/orders' +export interface MerchantOrderWorkbenchQuery { + cargoKeyword?: string + receivingWarehouseId?: string + channelName?: string + destinationType?: string + trackingNumber?: string + mainStatus?: string + createdFrom?: string + createdTo?: string + queue?: 'all' | 'fee' | 'customs' | 'draft' + page?: number + size?: number +} + +export interface MerchantOrderWorkbench { + page: PageResult + queues: { all: number; fee: number; customs: number; draft: number } + channelNames: string[] +} + +export function getMerchantOrderWorkbench(params: MerchantOrderWorkbenchQuery = {}) { + return apiRequest({ method: 'GET', url: `${unifiedOrderBaseUrl}/workbench`, + params: { ...params, page: params.page ?? 1, size: params.size ?? 10 } }) +} + export function getMerchantUnifiedOrders(params: MerchantUnifiedOrderQuery = {}) { const { page, size, ...filters } = params return apiRequest>({ @@ -171,7 +217,3 @@ export function getMerchantUnifiedOrder(orderId: number | string) { url: `${unifiedOrderBaseUrl}/${orderId}`, }) } - -export function isUnifiedOrderQueryUnavailable(error: unknown) { - return error instanceof ApiError && (error.status === 404 || error.code === 'A0404') -} diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs b/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs new file mode 100644 index 0000000..9c4860d --- /dev/null +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs @@ -0,0 +1,149 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' + +function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } } +function harness(t) { + const source = readFileSync(new URL('./CustomsDocumentsDialog.vue', import.meta.url), 'utf8').match(/ + + + + diff --git a/src/modules/first-leg/store/firstLegDraftCommands.test.mjs b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs new file mode 100644 index 0000000..9847ce6 --- /dev/null +++ b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs @@ -0,0 +1,430 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import * as pinia from 'pinia' + +function compile(path, dependencies) { + const source = readFileSync(new URL(path, import.meta.url), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText + const exports = {} + new Function('require', 'exports', code)(id => { assert.ok(id in dependencies, id); return dependencies[id] }, exports) + return exports +} +function harness(t) { + const context = pinia.createPinia(); pinia.setActivePinia(context) + t.after(() => pinia.disposePinia(context)) + const calls = [] + const behavior = { failWrite: false, failRead: false, version: 9, status: 'DRAFT' } + const api = compile('../api/firstLeg.ts', { '@/api/http': { apiRequest: async config => { + calls.push(structuredClone(config)) + if (config.method === 'GET') { + if (behavior.failRead) throw new Error('读取失败') + return { order: { id: 'one', orderNo: 'FL-ONE', version: behavior.version, status: behavior.status, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress, destinationCountryCode: 'US' }, cargoes: [] } + } + if (behavior.failWrite || (behavior.failSubmit && config.url.endsWith('/submit'))) throw new Error('响应丢失') + if (config.url.endsWith('/submit') && behavior.failSubmitRead) behavior.failRead = true + if (config.method === 'POST' && config.url === '/merchant/v1/orders/first-leg') return 'one' + } } }) + const module = compile('./firstLegOrders.ts', { + vue, pinia, + '@/modules/first-leg/api/firstLeg': api, + '@/modules/first-leg/api/unifiedOrders': { + getMerchantUnifiedOrder: async () => ({ summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }), + }, + '@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value }, + '@/modules/first-leg/utils/warehousePagination': {}, + }) + const store = module.useFirstLegOrdersStore() + store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC' }] + const draft = { receivingWarehouseId: 'warehouse', destinationType: 'PRIVATE', destinationCountryCode: 'US', items: [], customerRemark: '原草稿' } + return { store, api, calls, behavior, draft } +} + +function displayedWarehouseAddress(order) { + const view = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8') + const expressions = [...view.matchAll(/\{\{ (detailOrder\.receivingWarehouseSnapshot\.(?:province|city|address)) \}\}/g)] + assert.equal(expressions.length, 3) + return expressions.map(([, expression]) => new Function('detailOrder', `return ${expression}`)(order)).join('') +} + +test('complete address is identical for cold unified, warm unified, raw detail and remark-only save', async t => { + const h = harness(t) + const fullAddress = 'CN 浙江省 义乌市 仓库路 8 号 322000' + h.behavior.receivingWarehouseAddress = fullAddress + h.store.warehouses = [] + const cold = await h.store.loadDetail('one') + assert.equal(displayedWarehouseAddress(cold), fullAddress) + h.store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC', province: '浙江省', city: '义乌市', address: '主档已更新的地址' }] + h.store.orders = [] + const warm = await h.store.loadDetail('one') + const raw = await h.store.loadDraftDetail('one') + for (const detail of [warm, raw]) { + assert.equal(displayedWarehouseAddress(detail), fullAddress) + assert.equal(detail.receivingWarehouseSnapshot.province, '') + assert.equal(detail.receivingWarehouseSnapshot.city, '') + } + const session = h.store.beginDraftEdit(raw) + h.draft.customerRemark = '仅修改备注' + const saved = await h.store.saveDraft(h.draft, '客户', 'one', session) + assert.equal(displayedWarehouseAddress(saved), fullAddress) + const write = h.calls.find(call => call.method === 'PUT') + assert.equal(write.data.draft.customerRemark, '仅修改备注') + assert.equal(Object.hasOwn(write.data.draft, 'receivingWarehouseAddress'), false) + assert.equal(h.behavior.receivingWarehouseAddress, fullAddress) + assert.equal(h.store.warehouses[0].province, '浙江省') + assert.equal(h.store.warehouses[0].address, '主档已更新的地址') +}) + +test('structured master address stays structured when no order address snapshot exists', async t => { + const h = harness(t) + h.store.warehouses = [{ id: 'warehouse', name: '国内仓', province: '浙江省', city: '义乌市', address: '仓库路 8 号', warehouseType: 'DOMESTIC' }] + const detail = await h.store.loadDetail('one') + assert.equal(detail.receivingWarehouseSnapshot.province, '浙江省') + assert.equal(detail.receivingWarehouseSnapshot.city, '义乌市') + assert.equal(detail.receivingWarehouseSnapshot.address, '仓库路 8 号') + assert.equal(displayedWarehouseAddress(detail), '浙江省义乌市仓库路 8 号') +}) + +test('raw and unified cancelled detail remain CANCELLED even when unified mainStatus is null', async t => { + const h = harness(t) + h.behavior.status = 'CANCELLED' + const raw = await h.store.loadDraftDetail('one') + assert.equal(raw.status, 'CANCELLED') + const unified = await h.store.loadDetail('one') + assert.equal(unified.status, 'CANCELLED') + h.behavior.status = 'APPROVED' + const unknown = await h.store.loadDetail('one') + assert.notEqual(unknown.status, 'CANCELLED', 'unknown summary must not be reclassified as cancellation') +}) + +test('fresh direct draft detail preserves raw DRAFT with null unified summary and opens an edit session', async t => { + const h = harness(t) + assert.equal(h.store.orders.length, 0) + const direct = await h.store.loadDetail('one') + assert.equal(direct.status, 'DRAFT') + assert.equal(h.store.beginDraftEdit(direct).expectedVersion, 9) + h.store.orders = [] + const refreshed = await h.store.loadDetail('one') + assert.equal(refreshed.status, 'DRAFT') + h.store.orders = [] + h.behavior.status = 'APPROVED' + const unknown = await h.store.loadDetail('one') + assert.notEqual(unknown.status, 'DRAFT') + assert.notEqual(unknown.status, 'CANCELLED') + assert.throws(() => h.store.beginDraftEdit(unknown), /重新打开/) +}) + +test('cancelled detail summary has no pending overseas synchronization entries', () => { + const source = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8') + const script = source.match(/