From df4b7eba8e5bbe41c62c9271a774b0d622a3c3e0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 10:44:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- README.md | 4 +-- docker-compose.test.yml | 8 ++---- nginx/default.conf.template | 14 ++++++++- src/api/crossTabRefresh.test.mjs | 35 +++++++++++++++++++++-- src/api/crossTabRefresh.ts | 21 +++++++++++++- src/api/http.ts | 8 +++++- src/deployment/nginxConfig.test.mjs | 9 ++++++ src/modules/auth/views/AuthViews.test.mjs | 2 ++ 9 files changed, 89 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index bfb8669..6144c14 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ VITE_API_BASE_URL=/api VITE_OVERSEAS_API_BASE_URL=/overseas-api OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300 -API_PROXY_TARGET=http://8.163.30.128:9000 +API_PROXY_TARGET=http://127.0.0.1:9000 VITE_ADMIN_WEB_ORIGIN=http://localhost:5173 diff --git a/README.md b/README.md index 39edc79..b2a4b55 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ pnpm test 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 -一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到测试环境 `http://8.163.30.128:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 +一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。 @@ -47,4 +47,4 @@ pnpm test 头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 -联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://8.163.30.128:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。2026-07-31 测试 Gateway 登录和真实角色列表读取正常;Warehouse 服务已注册,但仓库和往来单位业务查询仍返回 `S0500`,头程草稿创建和相关浏览器流程需待后端修复后继续验收。 +联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d9e8b3c..53790ad 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -4,7 +4,7 @@ services: context: . dockerfile: Dockerfile args: - VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-http://8.163.30.128:9001}" + VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-}" NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" image: client-web-test:local @@ -12,8 +12,6 @@ services: restart: unless-stopped ports: - "${HOST_PORT:-9002}:80" - extra_hosts: - - "host.docker.internal:host-gateway" environment: - # Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。 - API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}" + API_UPSTREAM: "http://test-api-cn.buggoglobal.com" + OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com" diff --git a/nginx/default.conf.template b/nginx/default.conf.template index bd49fde..605ebfd 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -16,7 +16,19 @@ server { client_max_body_size 12m; proxy_pass ${API_UPSTREAM}; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $proxy_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 海外业务使用独立 Gateway;浏览器前缀只在此处改写一次为 /api。 + location /overseas-api/ { + client_max_body_size 12m; + rewrite ^/overseas-api/(.*)$ /api/$1 break; + proxy_pass ${OVERSEAS_API_UPSTREAM}; + proxy_http_version 1.1; + proxy_set_header Host $proxy_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/src/api/crossTabRefresh.test.mjs b/src/api/crossTabRefresh.test.mjs index 44ddd28..200c354 100644 --- a/src/api/crossTabRefresh.test.mjs +++ b/src/api/crossTabRefresh.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { coordinateCrossTabRefresh } from './crossTabRefresh.ts' +import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh.ts' test('reuses tokens rotated by another browser tab while waiting for the refresh lock', async () => { let currentRefreshToken = 'refresh-old' @@ -10,7 +10,7 @@ test('reuses tokens rotated by another browser tab while waiting for the refresh configurable: true, value: { locks: { - async request(_name, callback) { + async request(_name, _options, callback) { currentRefreshToken = 'refresh-new' return callback() }, @@ -41,7 +41,7 @@ test('refreshes normally when this tab owns the current refresh token', async () const originalNavigator = globalThis.navigator Object.defineProperty(globalThis, 'navigator', { configurable: true, - value: { locks: { request: async (_name, callback) => callback() } }, + value: { locks: { request: async (_name, _options, callback) => callback() } }, }) try { @@ -76,3 +76,32 @@ test('does not wait for rotation after the shared session was removed', async () Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) } }) + +test('aborts a refresh lock that cannot be acquired', async () => { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + request(_name, options) { + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true }) + }) + }, + }, + }, + }) + + try { + await assert.rejects(coordinateCrossTabRefresh({ + lockName: 'client-refresh', + lockTimeoutMs: 5, + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => 'refresh-old', + reuseCurrentSession: async () => 'unexpected', + refresh: async () => 'unexpected', + }), CrossTabRefreshTimeoutError) + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) + } +}) diff --git a/src/api/crossTabRefresh.ts b/src/api/crossTabRefresh.ts index 6f429ec..11252f2 100644 --- a/src/api/crossTabRefresh.ts +++ b/src/api/crossTabRefresh.ts @@ -1,11 +1,21 @@ export interface CrossTabRefreshOptions { lockName: string + lockTimeoutMs?: number observedRefreshToken: string currentRefreshToken: () => string reuseCurrentSession: () => Promise refresh: (refreshToken: string) => Promise } +export class CrossTabRefreshTimeoutError extends Error { + constructor() { + super('登录状态刷新等待超时,请重新登录') + this.name = 'CrossTabRefreshTimeoutError' + } +} + +const DEFAULT_LOCK_TIMEOUT_MS = 20_000 + export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptions): Promise { const run = async () => { const currentRefreshToken = options.currentRefreshToken() @@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptio throw error } } - return navigator.locks.request(options.lockName, run) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS) + try { + return await navigator.locks.request(options.lockName, { signal: controller.signal }, run) + } catch (error) { + if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError() + throw error + } finally { + clearTimeout(timeout) + } } diff --git a/src/api/http.ts b/src/api/http.ts index 50b2f63..c3df4fb 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -3,7 +3,7 @@ import { parseApiJsonResponse } from '@chongchong/shared' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' -import { coordinateCrossTabRefresh } from '@/api/crossTabRefresh' +import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh' const transport = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', @@ -78,6 +78,12 @@ export async function refreshAuthenticatedSession() { return tokens.accessToken }, }) + .catch((error) => { + if (error instanceof CrossTabRefreshTimeoutError) { + throw new ApiError(error.message, 'A0401', 401) + } + throw error + }) .finally(() => { refreshPromise = null }) diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index 64ba5e7..809f9b8 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import test from 'node:test' const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8') +const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8') const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8') const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8') @@ -10,6 +11,14 @@ test('allows qualification uploads with multipart overhead through the API proxy assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 12m;/) }) +test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => { + assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/) + assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/) + assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/) + assert.match(config, /proxy_set_header Host \$proxy_host;/) + assert.doesNotMatch(config, /\/api\/api/) +}) + test('runs each deployment on its target node without controller-local SSH files', () => { assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-dev'\) \? 'java-dev' : 'java-test'/) assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/) diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index 88bcd41..9939122 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -87,6 +87,8 @@ test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(httpSource, /return await transport\.request\(request\)/) assert.match(httpSource, /envelope\.code !== '200'/) assert.match(httpSource, /redirectToLoginAfterSessionExpiry/) + assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError/) + assert.match(httpSource, /new ApiError\(error\.message, 'A0401', 401\)/) assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/) assert.match(authStoreSource, /decodeMerchantToken/) assert.doesNotMatch(authStoreSource, /mock-client:/)