From 24fd3a8847064e6c07bd0040af62d62cc751e6a2 Mon Sep 17 00:00:00 2001 From: imeepos Date: Tue, 13 Jan 2026 16:00:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 Storage 层使用 AsyncStorage - 统一 Token 存储键名为 bestaibest.better-auth.session_token - 启用 401 自动跳转登录页并显示 Toast 提示 - 添加全局认证守卫(AuthGuard 组件) - 修复 x-ownerid header 配置(从环境变量读取商户ID) - 导出 loomart API 供活动数据使用 主要修改: - lib/storage.native.ts: 新建,使用 AsyncStorage - lib/storage.ts: 添加错误处理和注释 - lib/fetch-logger.ts: 统一 Token 键,启用 401 拦截 - lib/auth.ts: 导出 TOKEN_KEY,修复 x-ownerid,导出 loomart - app/_layout.tsx: 添加 AuthGuard 组件实现路由守卫 Co-Authored-By: Claude --- app/_layout.tsx | 105 ++++++++++++++++++++++++++++++++++-------- lib/auth.ts | 19 ++++++-- lib/fetch-logger.ts | 34 +++++++++++--- lib/storage.native.ts | 27 +++++++++++ lib/storage.ts | 31 ++++++++++--- 5 files changed, 181 insertions(+), 35 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 0f90027..e670a54 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,43 +1,110 @@ import '../global.css' import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native' -import { Stack } from 'expo-router' +import { Stack, usePathname, useRouter } from 'expo-router' import { StatusBar } from 'expo-status-bar' +import { useEffect } from 'react' import 'react-native-reanimated' import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { View, ActivityIndicator } from 'react-native' import { useColorScheme } from '@/hooks/use-color-scheme' import { KeyboardProvider } from 'react-native-keyboard-controller' import { SafeAreaProvider } from 'react-native-safe-area-context' import { ModalPortal } from '@/components/ui' import '@/lib/i18n' +import { useSession } from '@/lib/auth' export const unstable_settings = { anchor: '(tabs)', } +// 受保护的路由(需要登录才能访问) +const PROTECTED_ROUTES = [ + '/(tabs)', + '/channels', + '/generateVideo', + '/generationRecord', + '/worksList', + '/membership', + '/changePassword', +] + +// 公开路由(无需登录) +const PUBLIC_ROUTES = [ + '/auth', + '/modal', + '/searchTemplate', + '/searchResults', + '/searchWorks', + '/searchWorksResults', + '/templateDetail', + '/terms', + '/privacy', +] + +// 认证守卫组件 +function AuthGuard({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + const router = useRouter() + const { data: session, isPending } = useSession() + + useEffect(() => { + // 加载中不处理 + if (isPending) return + + // 检查是否在受保护路由 + const isProtectedRoute = PROTECTED_ROUTES.some(route => pathname?.startsWith(route)) + + // 未登录访问受保护路由 -> 跳转到登录页 + if (isProtectedRoute && !session?.user) { + router.replace('/auth') + return + } + + // 已登录访问登录页 -> 跳转到首页 + if (pathname === '/auth' && session?.user) { + router.replace('/(tabs)') + return + } + }, [pathname, session, isPending, router]) + + // 加载中显示 loading + if (isPending) { + return ( + + + + ) + } + + return <>{children} +} + // 路由层 export default function RootLayout() { return ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ) diff --git a/lib/auth.ts b/lib/auth.ts index 7febea8..a9a76fa 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,5 +1,6 @@ import 'reflect-metadata' +import Constants from 'expo-constants' import { expoClient } from '@better-auth/expo/client' import { createSkerClientPlugin } from '@repo/sdk' import { @@ -13,12 +14,15 @@ import { } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/react' -import { fetchWithLogger } from './fetch-logger' +import { fetchWithLogger, TOKEN_KEY } from './fetch-logger' import type { Subscription } from './plugins/stripe' import { stripeClient } from './plugins/stripe-plugin' import { storage } from './storage' import type { ApiError } from './types' +// 商户ID配置 - 从环境变量或应用配置中读取 +const MERCHANT_ID = Constants.expoConfig?.extra?.merchantId || '' + export interface CreditBalance { tokenUsage: number remainingTokenBalance: number @@ -72,10 +76,11 @@ export interface ISubscription { restore: (params?: SubscriptionRestoreParams) => Promise<{ data?: SubscriptionRestoreResponse; error?: ApiError }> billingPortal: (params?: BillingPortalParams) => Promise<{ data?: BillingPortalResponse; error?: ApiError }> } -export const getAuthToken = async () => (await storage.getItem('bestaibest.better-auth.session_token')) || '' +// 统一的 Token 存储键名(与 fetch-logger.ts 保持一致) +export const getAuthToken = async () => (await storage.getItem(TOKEN_KEY)) || '' export const setAuthToken = async (token: string) => { - await storage.setItem(`bestaibest.better-auth.session_token`, token) + await storage.setItem(TOKEN_KEY, token) } export const authClient = createAuthClient({ baseURL: 'https://api.mixvideo.bowong.cc', @@ -84,7 +89,9 @@ export const authClient = createAuthClient({ scheme: 'duooomi', fetchOptions: { headers: { - "x-ownerid": "" + // x-ownerid: 商户ID(如果后端需要) + // 如果 MERCHANT_ID 为空,则不添加此 header + ...(MERCHANT_ID && { 'x-ownerid': MERCHANT_ID }), }, auth: { type: 'Bearer', @@ -114,4 +121,8 @@ export const authClient = createAuthClient({ export const { signIn, signUp, signOut, useSession, $Infer, admin, forgetPassword, resetPassword, emailOtp } = authClient +// 导出 loomart API(来自 createSkerClientPlugin) +export const loomart = Reflect.get(authClient, 'loomart') + +// 导出 subscription API export const subscription: ISubscription = Reflect.get(authClient, 'subscription') diff --git a/lib/fetch-logger.ts b/lib/fetch-logger.ts index ac68839..0fc004e 100644 --- a/lib/fetch-logger.ts +++ b/lib/fetch-logger.ts @@ -1,6 +1,10 @@ -import { router } from 'expo-router' +import { router } from 'expo-router'; +import { Toast } from '@/components/ui/Toast'; -import { storage } from './storage' +import { storage } from './storage'; + +// 统一的 Token 存储键名(与 lib/auth.ts 保持一致) +export const TOKEN_KEY = 'bestaibest.better-auth.session_token'; interface FetchLoggerOptions { enableLogging?: boolean @@ -18,6 +22,9 @@ const defaultOptions: FetchLoggerOptions = { const originalFetch = global.fetch +// 防止重复跳转的标志 +let isRedirecting = false + export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => { const config = { ...defaultOptions, ...options } @@ -27,7 +34,8 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => { const method = init?.method || 'GET' try { - const token = await storage.getItem('token') + // 使用统一的 Token 存储键 + const token = await storage.getItem(TOKEN_KEY) if (token) { init = { @@ -41,10 +49,24 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => { const response = await originalFetch(input, init) - if (response.status === 401) { + // 401 未授权处理 + if (response.status === 401 && !isRedirecting) { console.warn('🔐 401 未授权,跳转到登录页') - // router.replace('/auth') - router.push('/auth') + isRedirecting = true + + // 显示 Toast 提示 + Toast.show('登录已过期,请重新登录') + + // 清除过期 Token + await storage.removeItem(TOKEN_KEY) + + // 跳转到登录页(使用 replace 避免返回栈问题) + router.replace('/auth') + + // 延迟重置标志(防止并发请求导致重复跳转) + setTimeout(() => { + isRedirecting = false + }, 1000) } return response diff --git a/lib/storage.native.ts b/lib/storage.native.ts index 8b13789..555fdea 100644 --- a/lib/storage.native.ts +++ b/lib/storage.native.ts @@ -1 +1,28 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +export const storage = { + async getItem(key: string): Promise { + try { + return await AsyncStorage.getItem(key); + } catch (error) { + console.error(`[Storage] Error getting item "${key}":`, error); + return null; + } + }, + + async setItem(key: string, value: string): Promise { + try { + await AsyncStorage.setItem(key, value); + } catch (error) { + console.error(`[Storage] Error setting item "${key}":`, error); + } + }, + + async removeItem(key: string): Promise { + try { + await AsyncStorage.removeItem(key); + } catch (error) { + console.error(`[Storage] Error removing item "${key}":`, error); + } + }, +}; diff --git a/lib/storage.ts b/lib/storage.ts index 73c788e..5283924 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,17 +1,36 @@ +/** + * Web平台存储实现 (使用 localStorage) + * Native平台会自动使用 storage.native.ts (使用 AsyncStorage) + */ + declare const window: any; + export const storage = { async getItem(key: string): Promise { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(key); + try { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(key); + } catch (error) { + console.error(`[Storage] Error getting item "${key}":`, error); + return null; + } }, async setItem(key: string, value: string): Promise { - if (typeof window === "undefined") return; - window.localStorage.setItem(key, value); + try { + if (typeof window === "undefined") return; + window.localStorage.setItem(key, value); + } catch (error) { + console.error(`[Storage] Error setting item "${key}":`, error); + } }, async removeItem(key: string): Promise { - if (typeof window === "undefined") return; - window.localStorage.removeItem(key); + try { + if (typeof window === "undefined") return; + window.localStorage.removeItem(key); + } catch (error) { + console.error(`[Storage] Error removing item "${key}":`, error); + } }, };