fix(auth): 守卫改到 navigation 统一出口,修复拦截器误改写请求 url

This commit is contained in:
ozh 2026-09-15 17:26:47 +08:00
parent 55c3ab8874
commit bd71d52c7f
4 changed files with 80 additions and 79 deletions

View File

@ -1,13 +1,9 @@
import { PropsWithChildren } from 'react'
import { Provider } from 'react-redux'
import { store } from './store'
import { setupAuthInterceptors } from './utils/auth'
import './styles/reset.scss'
// 路由登录拦截需在任何导航发生前注册(模块加载时机,非组件渲染时机)
setupAuthInterceptors()
function App({ children }: PropsWithChildren) {
return <Provider store={store}>{children}</Provider>
}

View File

@ -1,36 +1,18 @@
import Taro from '@tarojs/taro'
import { ROUTES } from '@/constants/routes'
import { store } from '@/store'
import { storage } from '@/utils/storage'
// 路由级登录拦截(auth guard)
// 原理:Taro.addInterceptor 洋葱拦截 navigateTo/reLaunch/redirectTo/switchTab,
// 未登录时改写目标 url 为登录页。冷启动直达场景由各受保护页面 useDidShow 兜底。
// 路由级登录守卫(auth guard)
// addInterceptor 对所有 Taro API 生效(含 Taro.request,同样带 url 字段),
// 在拦截器内改写 url 会把 http 请求地址误改成页面路径(request:fail invalid url),
// 故不用全局拦截器,改为在 navigation 统一出口做跳转前校验。
// 登录页及无需登录即可访问的页面(放行名单)
const PUBLIC_PAGES = new Set<string>([ROUTES.LOGIN])
export const PUBLIC_PAGES = new Set<string>([ROUTES.LOGIN])
// 页面是否需要登录态
function isProtectedPage(url: string): boolean {
// 登录页路径(navigation 守卫改投目标)
export const ROUTES_LOGIN = ROUTES.LOGIN
// 页面是否需要登录态(入参为页面跳转 url,非 http url)
export function isProtectedPage(url: string): boolean {
const page = url.split('?')[0]
return !PUBLIC_PAGES.has(page)
}
function isLoggedIn(): boolean {
// 双读:store 内尚未恢复时(理论上仅冷启动瞬态)从 storage 兜底
return Boolean(store.getState().auth.token ?? storage.get<string>('auth_token'))
}
// 受保护页面跳转前校验登录态,未登录改投登录页
export function authRouteInterceptor(chain: Taro.Chain): Promise<unknown> {
const { url } = chain.requestParams
// addInterceptor 对所有 Taro API 生效(非仅导航类):无 url 的 API(toast/login 等)直接放行
if (typeof url === 'string' && url !== '' && isProtectedPage(url) && !isLoggedIn()) {
return chain.proceed({ ...chain.requestParams, url: ROUTES.LOGIN })
}
return chain.proceed(chain.requestParams)
}
export function setupAuthInterceptors(): void {
Taro.addInterceptor(authRouteInterceptor)
}

View File

@ -1,6 +1,20 @@
import Taro from '@tarojs/taro'
import { store } from '@/store'
import { storage } from '@/utils/storage'
import { isProtectedPage, ROUTES_LOGIN } from './auth'
export const navigateTo = (url: string) => Taro.navigateTo({ url })
// 登录态双读:store 内尚未恢复时(理论上仅冷启动瞬态)从 storage 兜底
function isLoggedIn(): boolean {
return Boolean(store.getState().auth.token ?? storage.get<string>('auth_token'))
}
// 受保护页面跳转前校验登录态,未登录改投登录页
function guardUrl(url: string): string {
return isProtectedPage(url) && !isLoggedIn() ? ROUTES_LOGIN : url
}
export const navigateTo = (url: string) => Taro.navigateTo({ url: guardUrl(url) })
export const navigateBack = (delta = 1) => Taro.navigateBack({ delta })
export const redirectTo = (url: string) => Taro.redirectTo({ url })
export const reLaunch = (url: string) => Taro.reLaunch({ url })
export const redirectTo = (url: string) => Taro.redirectTo({ url: guardUrl(url) })
export const reLaunch = (url: string) => Taro.reLaunch({ url: guardUrl(url) })
export const switchTab = (url: string) => Taro.switchTab({ url: guardUrl(url) })

View File

@ -1,7 +1,19 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// auth.ts 顶层 import Taro,需 mock(拦截器测试本身不触达 Taro API)
vi.mock('@tarojs/taro', () => ({ default: {} }))
// navigation → Taro 只透传最终参数,mock 记录调用
const mockNavigateTo = vi.hoisted(() => vi.fn())
const mockReLaunch = vi.hoisted(() => vi.fn())
const mockRedirectTo = vi.hoisted(() => vi.fn())
const mockSwitchTab = vi.hoisted(() => vi.fn())
vi.mock('@tarojs/taro', () => ({
default: {
navigateTo: mockNavigateTo,
reLaunch: mockReLaunch,
redirectTo: mockRedirectTo,
switchTab: mockSwitchTab,
navigateBack: vi.fn()
}
}))
// store mock:authState 可变,支持模拟已登录/未登录两种状态
const authState = vi.hoisted(() => ({ token: null as string | null }))
@ -21,64 +33,61 @@ vi.mock('@/utils/storage', () => ({
}
}))
import { authRouteInterceptor } from '@/utils/auth'
import { navigateTo, reLaunch, redirectTo, switchTab } from '@/utils/navigation'
import { isProtectedPage } from '@/utils/auth'
import { ROUTES } from '@/constants/routes'
// 构造拦截器链:记录 proceed 收到的参数,返回受控 promise
function makeChain(url?: string) {
const received: unknown[] = []
const chain = {
requestParams: url === undefined ? {} : { url },
proceed(params: unknown) {
received.push(params)
return Promise.resolve('proceeded')
}
}
return { chain, received }
}
beforeEach(() => {
authState.token = null
mem.clear()
mockNavigateTo.mockClear()
mockReLaunch.mockClear()
mockRedirectTo.mockClear()
mockSwitchTab.mockClear()
})
describe('authRouteInterceptor', () => {
it('未登录访问受保护页:url 被改写为登录页', async () => {
const { chain, received } = makeChain(ROUTES.HOME)
await authRouteInterceptor(chain as never)
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
describe('navigation 登录守卫', () => {
it('未登录 navigateTo 受保护页:url 被改写为登录页', () => {
navigateTo(ROUTES.FAMILY)
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
})
it('已登录访问受保护页:原样放行', async () => {
it('未登录 reLaunch/redirectTo/switchTab 同样被守卫', () => {
reLaunch(ROUTES.HOME)
redirectTo(ROUTES.DEVICE_ADD)
switchTab(ROUTES.EVENT)
expect(mockReLaunch).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
expect(mockRedirectTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
expect(mockSwitchTab).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
})
it('已登录 navigateTo:原样放行', () => {
authState.token = 'tk-1'
const { chain, received } = makeChain(ROUTES.DEVICE_ADD)
await authRouteInterceptor(chain as never)
expect(received[0]).toMatchObject({ url: ROUTES.DEVICE_ADD })
navigateTo(ROUTES.FAMILY)
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.FAMILY })
})
it('未登录访问登录页:放行(登录页在公开名单)', async () => {
const { chain, received } = makeChain(ROUTES.LOGIN)
await authRouteInterceptor(chain as never)
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
it('未登录访问登录页:放行(登录页在公开名单)', () => {
navigateTo(ROUTES.LOGIN)
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
})
it('带 query 的受保护页:仅取路径判断,跳登录页不带残留参数', async () => {
const { chain, received } = makeChain(`${ROUTES.FAMILY_DETAIL}?id=3`)
await authRouteInterceptor(chain as never)
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
it('带 query 的受保护页:跳登录页不带残留参数', () => {
navigateTo(`${ROUTES.FAMILY_DETAIL}?id=3`)
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
})
it('store 无 token 但 storage 有(冷启动兜底):放行', async () => {
it('store 无 token 但 storage 有(冷启动兜底):放行', () => {
mem.set('auth_token', 'tk-saved')
const { chain, received } = makeChain(ROUTES.HOME)
await authRouteInterceptor(chain as never)
expect(received[0]).toMatchObject({ url: ROUTES.HOME })
})
it('无 url 的 API 调用(toast/login 等,拦截器全局生效):原样放行', async () => {
const { chain, received } = makeChain(undefined)
await authRouteInterceptor(chain as never)
expect(received).toHaveLength(1)
expect(received[0]).toEqual({})
navigateTo(ROUTES.HOME)
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.HOME })
})
})
describe('isProtectedPage', () => {
it('登录页返回 false,其余页面返回 true', () => {
expect(isProtectedPage(ROUTES.LOGIN)).toBe(false)
expect(isProtectedPage(ROUTES.HOME)).toBe(true)
expect(isProtectedPage(`${ROUTES.LOGIN}?from=xxx`)).toBe(false)
})
})