feat: 完善认证系统
- 修复 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 <noreply@anthropic.com>
This commit is contained in:
parent
1a6df3c806
commit
24fd3a8847
|
|
@ -1,25 +1,91 @@
|
||||||
import '../global.css'
|
import '../global.css'
|
||||||
|
|
||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
|
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 { StatusBar } from 'expo-status-bar'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import 'react-native-reanimated'
|
import 'react-native-reanimated'
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||||
|
import { View, ActivityIndicator } from 'react-native'
|
||||||
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme'
|
import { useColorScheme } from '@/hooks/use-color-scheme'
|
||||||
import { KeyboardProvider } from 'react-native-keyboard-controller'
|
import { KeyboardProvider } from 'react-native-keyboard-controller'
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||||
import { ModalPortal } from '@/components/ui'
|
import { ModalPortal } from '@/components/ui'
|
||||||
import '@/lib/i18n'
|
import '@/lib/i18n'
|
||||||
|
import { useSession } from '@/lib/auth'
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
anchor: '(tabs)',
|
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 (
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#090A0B' }}>
|
||||||
|
<ActivityIndicator size="large" color="#9966FF" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
// 路由层
|
// 路由层
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
|
<AuthGuard>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen name="auth" options={{ headerShown: false }} />
|
<Stack.Screen name="auth" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||||
|
|
@ -38,6 +104,7 @@ export default function RootLayout() {
|
||||||
<Stack.Screen name="searchWorksResults" options={{ headerShown: false }} />
|
<Stack.Screen name="searchWorksResults" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="changePassword" options={{ headerShown: false }} />
|
<Stack.Screen name="changePassword" options={{ headerShown: false }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</AuthGuard>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
</Providers>
|
</Providers>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
19
lib/auth.ts
19
lib/auth.ts
|
|
@ -1,5 +1,6 @@
|
||||||
import 'reflect-metadata'
|
import 'reflect-metadata'
|
||||||
|
|
||||||
|
import Constants from 'expo-constants'
|
||||||
import { expoClient } from '@better-auth/expo/client'
|
import { expoClient } from '@better-auth/expo/client'
|
||||||
import { createSkerClientPlugin } from '@repo/sdk'
|
import { createSkerClientPlugin } from '@repo/sdk'
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,12 +14,15 @@ import {
|
||||||
} from 'better-auth/client/plugins'
|
} from 'better-auth/client/plugins'
|
||||||
import { createAuthClient } from 'better-auth/react'
|
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 type { Subscription } from './plugins/stripe'
|
||||||
import { stripeClient } from './plugins/stripe-plugin'
|
import { stripeClient } from './plugins/stripe-plugin'
|
||||||
import { storage } from './storage'
|
import { storage } from './storage'
|
||||||
import type { ApiError } from './types'
|
import type { ApiError } from './types'
|
||||||
|
|
||||||
|
// 商户ID配置 - 从环境变量或应用配置中读取
|
||||||
|
const MERCHANT_ID = Constants.expoConfig?.extra?.merchantId || ''
|
||||||
|
|
||||||
export interface CreditBalance {
|
export interface CreditBalance {
|
||||||
tokenUsage: number
|
tokenUsage: number
|
||||||
remainingTokenBalance: number
|
remainingTokenBalance: number
|
||||||
|
|
@ -72,10 +76,11 @@ export interface ISubscription {
|
||||||
restore: (params?: SubscriptionRestoreParams) => Promise<{ data?: SubscriptionRestoreResponse; error?: ApiError }>
|
restore: (params?: SubscriptionRestoreParams) => Promise<{ data?: SubscriptionRestoreResponse; error?: ApiError }>
|
||||||
billingPortal: (params?: BillingPortalParams) => Promise<{ data?: BillingPortalResponse; 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) => {
|
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({
|
export const authClient = createAuthClient({
|
||||||
baseURL: 'https://api.mixvideo.bowong.cc',
|
baseURL: 'https://api.mixvideo.bowong.cc',
|
||||||
|
|
@ -84,7 +89,9 @@ export const authClient = createAuthClient({
|
||||||
scheme: 'duooomi',
|
scheme: 'duooomi',
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
headers: {
|
headers: {
|
||||||
"x-ownerid": ""
|
// x-ownerid: 商户ID(如果后端需要)
|
||||||
|
// 如果 MERCHANT_ID 为空,则不添加此 header
|
||||||
|
...(MERCHANT_ID && { 'x-ownerid': MERCHANT_ID }),
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
type: 'Bearer',
|
type: 'Bearer',
|
||||||
|
|
@ -114,4 +121,8 @@ export const authClient = createAuthClient({
|
||||||
export const { signIn, signUp, signOut, useSession, $Infer, admin, forgetPassword, resetPassword, emailOtp } =
|
export const { signIn, signUp, signOut, useSession, $Infer, admin, forgetPassword, resetPassword, emailOtp } =
|
||||||
authClient
|
authClient
|
||||||
|
|
||||||
|
// 导出 loomart API(来自 createSkerClientPlugin)
|
||||||
|
export const loomart = Reflect.get(authClient, 'loomart')
|
||||||
|
|
||||||
|
// 导出 subscription API
|
||||||
export const subscription: ISubscription = Reflect.get(authClient, 'subscription')
|
export const subscription: ISubscription = Reflect.get(authClient, 'subscription')
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
interface FetchLoggerOptions {
|
||||||
enableLogging?: boolean
|
enableLogging?: boolean
|
||||||
|
|
@ -18,6 +22,9 @@ const defaultOptions: FetchLoggerOptions = {
|
||||||
|
|
||||||
const originalFetch = global.fetch
|
const originalFetch = global.fetch
|
||||||
|
|
||||||
|
// 防止重复跳转的标志
|
||||||
|
let isRedirecting = false
|
||||||
|
|
||||||
export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
|
export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
|
||||||
const config = { ...defaultOptions, ...options }
|
const config = { ...defaultOptions, ...options }
|
||||||
|
|
||||||
|
|
@ -27,7 +34,8 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
|
||||||
const method = init?.method || 'GET'
|
const method = init?.method || 'GET'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await storage.getItem('token')
|
// 使用统一的 Token 存储键
|
||||||
|
const token = await storage.getItem(TOKEN_KEY)
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
init = {
|
init = {
|
||||||
|
|
@ -41,10 +49,24 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
|
||||||
|
|
||||||
const response = await originalFetch(input, init)
|
const response = await originalFetch(input, init)
|
||||||
|
|
||||||
if (response.status === 401) {
|
// 401 未授权处理
|
||||||
|
if (response.status === 401 && !isRedirecting) {
|
||||||
console.warn('🔐 401 未授权,跳转到登录页')
|
console.warn('🔐 401 未授权,跳转到登录页')
|
||||||
// router.replace('/auth')
|
isRedirecting = true
|
||||||
router.push('/auth')
|
|
||||||
|
// 显示 Toast 提示
|
||||||
|
Toast.show('登录已过期,请重新登录')
|
||||||
|
|
||||||
|
// 清除过期 Token
|
||||||
|
await storage.removeItem(TOKEN_KEY)
|
||||||
|
|
||||||
|
// 跳转到登录页(使用 replace 避免返回栈问题)
|
||||||
|
router.replace('/auth')
|
||||||
|
|
||||||
|
// 延迟重置标志(防止并发请求导致重复跳转)
|
||||||
|
setTimeout(() => {
|
||||||
|
isRedirecting = false
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -1 +1,28 @@
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
export const storage = {
|
||||||
|
async getItem(key: string): Promise<string | null> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(key, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Storage] Error setting item "${key}":`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.removeItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Storage] Error removing item "${key}":`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Web平台存储实现 (使用 localStorage)
|
||||||
|
* Native平台会自动使用 storage.native.ts (使用 AsyncStorage)
|
||||||
|
*/
|
||||||
|
|
||||||
declare const window: any;
|
declare const window: any;
|
||||||
|
|
||||||
export const storage = {
|
export const storage = {
|
||||||
async getItem(key: string): Promise<string | null> {
|
async getItem(key: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
return window.localStorage.getItem(key);
|
return window.localStorage.getItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Storage] Error getting item "${key}":`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async setItem(key: string, value: string): Promise<void> {
|
async setItem(key: string, value: string): Promise<void> {
|
||||||
|
try {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
window.localStorage.setItem(key, value);
|
window.localStorage.setItem(key, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Storage] Error setting item "${key}":`, error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async removeItem(key: string): Promise<void> {
|
async removeItem(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
window.localStorage.removeItem(key);
|
window.localStorage.removeItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Storage] Error removing item "${key}":`, error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue