feat(auth): 开启路由级登录拦截,未登录访问受保护页重定向登录页

This commit is contained in:
ozh 2026-09-15 16:04:40 +08:00
parent 9581a991be
commit f9d0e3ba7f
4 changed files with 132 additions and 1 deletions

View File

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

View File

@ -6,8 +6,10 @@ import { Loading } from '@/components/loading'
import { Empty } from '@/components/empty' import { Empty } from '@/components/empty'
import { StateView } from '@/components/state-view' import { StateView } from '@/components/state-view'
import { getEventList, type EventItem } from '@/api/event' import { getEventList, type EventItem } from '@/api/event'
import { store } from '@/store'
import { storage } from '@/utils/storage'
import { ROUTES } from '@/constants/routes' import { ROUTES } from '@/constants/routes'
import { navigateTo } from '@/utils/navigation' import { navigateTo, reLaunch } from '@/utils/navigation'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { splitAlerts } from './splitAlerts' import { splitAlerts } from './splitAlerts'
import styles from './index.module.scss' import styles from './index.module.scss'
@ -31,7 +33,12 @@ export default function Index() {
} }
}, []) }, [])
// 冷启动直达兜底:小程序入口页不受 addInterceptor 保护,未登录时踢到登录页
useDidShow(() => { useDidShow(() => {
if (!store.getState().auth.token && !storage.get<string>('auth_token')) {
reLaunch(ROUTES.LOGIN)
return
}
loadAlerts() loadAlerts()
}) })

36
src/utils/auth.ts Normal file
View File

@ -0,0 +1,36 @@
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 兜底。
// 登录页及无需登录即可访问的页面(放行名单)
const PUBLIC_PAGES = new Set<string>([ROUTES.LOGIN])
// 页面是否需要登录态
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

@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// auth.ts 顶层 import Taro,需 mock(拦截器测试本身不触达 Taro API)
vi.mock('@tarojs/taro', () => ({ default: {} }))
// store mock:authState 可变,支持模拟已登录/未登录两种状态
const authState = vi.hoisted(() => ({ token: null as string | null }))
vi.mock('@/store', () => ({
store: {
getState: () => ({ auth: { token: authState.token } })
}
}))
// storage mock:内存 Map,用于验证 store 未恢复时的兜底读取
const mem = vi.hoisted(() => new Map<string, unknown>())
vi.mock('@/utils/storage', () => ({
storage: {
get: <T>(key: string): T | null => (mem.has(key) ? (mem.get(key) as T) : null),
set: <T>(key: string, value: T): void => { mem.set(key, value) },
remove: (key: string): void => { mem.delete(key) }
}
}))
import { authRouteInterceptor } 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()
})
describe('authRouteInterceptor', () => {
it('未登录访问受保护页:url 被改写为登录页', async () => {
const { chain, received } = makeChain(ROUTES.HOME)
await authRouteInterceptor(chain as never)
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
})
it('已登录访问受保护页:原样放行', async () => {
authState.token = 'tk-1'
const { chain, received } = makeChain(ROUTES.DEVICE_ADD)
await authRouteInterceptor(chain as never)
expect(received[0]).toMatchObject({ url: ROUTES.DEVICE_ADD })
})
it('未登录访问登录页:放行(登录页在公开名单)', async () => {
const { chain, received } = makeChain(ROUTES.LOGIN)
await authRouteInterceptor(chain as never)
expect(received[0]).toMatchObject({ 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('store 无 token 但 storage 有(冷启动兜底):放行', async () => {
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({})
})
})