优化
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
export interface CrossTabRefreshOptions<T> {
|
||||
lockName: string
|
||||
lockTimeoutMs?: number
|
||||
observedRefreshToken: string
|
||||
currentRefreshToken: () => string
|
||||
reuseCurrentSession: () => Promise<T>
|
||||
refresh: (refreshToken: string) => Promise<T>
|
||||
}
|
||||
|
||||
export class CrossTabRefreshTimeoutError extends Error {
|
||||
constructor() {
|
||||
super('登录状态刷新等待超时,请重新登录')
|
||||
this.name = 'CrossTabRefreshTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
|
||||
|
||||
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
||||
const run = async () => {
|
||||
const currentRefreshToken = options.currentRefreshToken()
|
||||
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(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)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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
|
||||
})
|
||||
|
||||
@@ -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'/)
|
||||
|
||||
@@ -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:/)
|
||||
|
||||
Reference in New Issue
Block a user