This commit is contained in:
2026-08-27 10:44:12 +08:00
parent f7afcb6cdc
commit 506d310211
7 changed files with 83 additions and 25 deletions
+3 -8
View File
@@ -11,12 +11,7 @@ services:
restart: unless-stopped
ports:
- "${HOST_PORT:-9001}:80"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://8.163.30.128:9002}"
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。
# 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或
# 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。
API_UPSTREAM: "http://host.docker.internal:9000"
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-}"
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
+2 -2
View File
@@ -24,7 +24,7 @@ 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;
@@ -36,7 +36,7 @@ server {
rewrite ^/overseas-api/(.*)$ /api/$1 break;
proxy_pass ${OVERSEAS_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;
+29 -1
View File
@@ -1,5 +1,5 @@
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(() => ({
post: vi.fn(),
@@ -89,6 +89,8 @@ describe('admin auth session', () => {
mocks.refreshHandler = null
})
afterEach(() => vi.unstubAllGlobals())
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
@@ -203,6 +205,32 @@ describe('admin auth session', () => {
.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 () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
+3 -8
View File
@@ -203,14 +203,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
observedRefreshToken,
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
reuseCurrentSession: async () => {
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh',
{ refreshToken: storedRefreshToken },
)
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
applyStoredSession()
ensureCurrentGeneration(expectedGeneration)
if (!token.value || !refreshToken.value) throw new Error('登录状态已过期')
},
refresh: async (currentRefreshToken) => {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
+22 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { coordinateCrossTabRefresh } from './crossTabRefresh'
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh'
afterEach(() => vi.unstubAllGlobals())
@@ -10,7 +10,7 @@ describe('cross-tab refresh coordination', () => {
const reuseCurrentSession = vi.fn(async () => 'access-new')
vi.stubGlobal('navigator', {
locks: {
request: vi.fn(async (_name: string, callback: () => Promise<string>) => {
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => {
currentRefreshToken = 'refresh-new'
return callback()
}),
@@ -32,7 +32,7 @@ describe('cross-tab refresh coordination', () => {
it('performs the refresh when the token has not changed', async () => {
const refresh = vi.fn(async () => 'access-new')
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({
@@ -61,4 +61,23 @@ describe('cross-tab refresh coordination', () => {
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)
})
})
+20 -1
View File
@@ -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)
}
}
+4 -2
View File
@@ -36,8 +36,10 @@ describe('admin nginx deployment config', () => {
})
it('declares an explicit overseas gateway upstream in container deployments', () => {
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"')
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', () => {