优化
This commit is contained in:
@@ -11,12 +11,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-9001}:80"
|
- "${HOST_PORT:-9001}:80"
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
environment:
|
environment:
|
||||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://8.163.30.128:9002}"
|
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-}"
|
||||||
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。
|
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
|
||||||
# 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或
|
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
|
||||||
# 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。
|
|
||||||
API_UPSTREAM: "http://host.docker.internal:9000"
|
|
||||||
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ server {
|
|||||||
client_max_body_size 12m;
|
client_max_body_size 12m;
|
||||||
proxy_pass ${API_UPSTREAM};
|
proxy_pass ${API_UPSTREAM};
|
||||||
proxy_http_version 1.1;
|
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-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
rewrite ^/overseas-api/(.*)$ /api/$1 break;
|
rewrite ^/overseas-api/(.*)$ /api/$1 break;
|
||||||
proxy_pass ${OVERSEAS_API_UPSTREAM};
|
proxy_pass ${OVERSEAS_API_UPSTREAM};
|
||||||
proxy_http_version 1.1;
|
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-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
@@ -89,6 +89,8 @@ describe('admin auth session', () => {
|
|||||||
mocks.refreshHandler = null
|
mocks.refreshHandler = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
|
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
|
||||||
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
|
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
|
||||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||||
@@ -203,6 +205,32 @@ describe('admin auth session', () => {
|
|||||||
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
|
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reuses a session rotated by another tab without refreshing it again', async () => {
|
||||||
|
const previousToken = token({ token_version: 1 })
|
||||||
|
const rotatedToken = token({ token_version: 2 })
|
||||||
|
localStorage.setItem('cc-admin-token', previousToken)
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
locks: {
|
||||||
|
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<void>) => {
|
||||||
|
localStorage.setItem('cc-admin-token', rotatedToken)
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated')
|
||||||
|
localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]')
|
||||||
|
return callback()
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
await expect(auth.refreshSession()).resolves.toBeUndefined()
|
||||||
|
|
||||||
|
expect(mocks.post).not.toHaveBeenCalled()
|
||||||
|
expect(auth.token).toBe(rotatedToken)
|
||||||
|
expect(auth.refreshToken).toBe('refresh-rotated')
|
||||||
|
expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list'])
|
||||||
|
})
|
||||||
|
|
||||||
it('does not let an in-flight refresh restore a session after logout', async () => {
|
it('does not let an in-flight refresh restore a session after logout', async () => {
|
||||||
localStorage.setItem('cc-admin-token', token())
|
localStorage.setItem('cc-admin-token', token())
|
||||||
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
|||||||
@@ -203,14 +203,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
observedRefreshToken,
|
observedRefreshToken,
|
||||||
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
|
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
|
||||||
reuseCurrentSession: async () => {
|
reuseCurrentSession: async () => {
|
||||||
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
|
applyStoredSession()
|
||||||
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
ensureCurrentGeneration(expectedGeneration)
|
||||||
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
|
if (!token.value || !refreshToken.value) throw new Error('登录状态已过期')
|
||||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
|
||||||
'/auth/refresh',
|
|
||||||
{ refreshToken: storedRefreshToken },
|
|
||||||
)
|
|
||||||
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
|
|
||||||
},
|
},
|
||||||
refresh: async (currentRefreshToken) => {
|
refresh: async (currentRefreshToken) => {
|
||||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh'
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ describe('cross-tab refresh coordination', () => {
|
|||||||
const reuseCurrentSession = vi.fn(async () => 'access-new')
|
const reuseCurrentSession = vi.fn(async () => 'access-new')
|
||||||
vi.stubGlobal('navigator', {
|
vi.stubGlobal('navigator', {
|
||||||
locks: {
|
locks: {
|
||||||
request: vi.fn(async (_name: string, callback: () => Promise<string>) => {
|
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => {
|
||||||
currentRefreshToken = 'refresh-new'
|
currentRefreshToken = 'refresh-new'
|
||||||
return callback()
|
return callback()
|
||||||
}),
|
}),
|
||||||
@@ -32,7 +32,7 @@ describe('cross-tab refresh coordination', () => {
|
|||||||
it('performs the refresh when the token has not changed', async () => {
|
it('performs the refresh when the token has not changed', async () => {
|
||||||
const refresh = vi.fn(async () => 'access-new')
|
const refresh = vi.fn(async () => 'access-new')
|
||||||
vi.stubGlobal('navigator', {
|
vi.stubGlobal('navigator', {
|
||||||
locks: { request: vi.fn(async (_name: string, callback: () => Promise<string>) => callback()) },
|
locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => callback()) },
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(coordinateCrossTabRefresh({
|
await expect(coordinateCrossTabRefresh({
|
||||||
@@ -61,4 +61,23 @@ describe('cross-tab refresh coordination', () => {
|
|||||||
|
|
||||||
expect(refresh).toHaveBeenCalledWith('refresh-old')
|
expect(refresh).toHaveBeenCalledWith('refresh-old')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('aborts a refresh lock that cannot be acquired', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
locks: {
|
||||||
|
request: vi.fn((_name: string, options: LockOptions) => new Promise((_resolve, reject) => {
|
||||||
|
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinateCrossTabRefresh({
|
||||||
|
lockName: 'admin-refresh',
|
||||||
|
lockTimeoutMs: 5,
|
||||||
|
observedRefreshToken: 'refresh-old',
|
||||||
|
currentRefreshToken: () => 'refresh-old',
|
||||||
|
reuseCurrentSession: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
})).rejects.toBeInstanceOf(CrossTabRefreshTimeoutError)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
export interface CrossTabRefreshOptions<T> {
|
export interface CrossTabRefreshOptions<T> {
|
||||||
lockName: string
|
lockName: string
|
||||||
|
lockTimeoutMs?: number
|
||||||
observedRefreshToken: string
|
observedRefreshToken: string
|
||||||
currentRefreshToken: () => string
|
currentRefreshToken: () => string
|
||||||
reuseCurrentSession: () => Promise<T>
|
reuseCurrentSession: () => Promise<T>
|
||||||
refresh: (refreshToken: string) => 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> {
|
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const currentRefreshToken = options.currentRefreshToken()
|
const currentRefreshToken = options.currentRefreshToken()
|
||||||
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
|
|||||||
throw error
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ describe('admin nginx deployment config', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('declares an explicit overseas gateway upstream in container deployments', () => {
|
it('declares an explicit overseas gateway upstream in container deployments', () => {
|
||||||
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"')
|
||||||
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"')
|
||||||
|
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"')
|
||||||
|
expect(config).toContain('proxy_set_header Host $proxy_host;')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('runs each deployment on its target node without controller-local SSH files', () => {
|
it('runs each deployment on its target node without controller-local SSH files', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user