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:
imeepos 2026-01-13 16:00:02 +08:00
parent 1a6df3c806
commit 24fd3a8847
5 changed files with 181 additions and 35 deletions

View File

@ -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 (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#090A0B' }}>
<ActivityIndicator size="large" color="#9966FF" />
</View>
)
}
return <>{children}</>
}
// 路由层
export default function RootLayout() {
return (
<Providers>
<Stack>
<Stack.Screen name="auth" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="channels" options={{ headerShown: false }} />
<Stack.Screen name="searchTemplate" options={{ headerShown: false }} />
<Stack.Screen name="searchResults" options={{ headerShown: false }} />
<Stack.Screen name="generationRecord" options={{ headerShown: false }} />
<Stack.Screen name="generateVideo" options={{ headerShown: false }} />
<Stack.Screen name="templateDetail" options={{ headerShown: false }} />
<Stack.Screen name="membership" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="worksList" options={{ headerShown: false }} />
<Stack.Screen name="searchWorks" options={{ headerShown: false }} />
<Stack.Screen name="searchWorksResults" options={{ headerShown: false }} />
<Stack.Screen name="changePassword" options={{ headerShown: false }} />
</Stack>
<AuthGuard>
<Stack>
<Stack.Screen name="auth" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="channels" options={{ headerShown: false }} />
<Stack.Screen name="searchTemplate" options={{ headerShown: false }} />
<Stack.Screen name="searchResults" options={{ headerShown: false }} />
<Stack.Screen name="generationRecord" options={{ headerShown: false }} />
<Stack.Screen name="generateVideo" options={{ headerShown: false }} />
<Stack.Screen name="templateDetail" options={{ headerShown: false }} />
<Stack.Screen name="membership" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="worksList" options={{ headerShown: false }} />
<Stack.Screen name="searchWorks" options={{ headerShown: false }} />
<Stack.Screen name="searchWorksResults" options={{ headerShown: false }} />
<Stack.Screen name="changePassword" options={{ headerShown: false }} />
</Stack>
</AuthGuard>
<StatusBar style="auto" />
</Providers>
)

View File

@ -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')

View File

@ -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

View File

@ -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);
}
},
};

View File

@ -1,17 +1,36 @@
/**
* Web平台存储实现 (使 localStorage)
* Native平台会自动使用 storage.native.ts (使 AsyncStorage)
*/
declare const window: any;
export const storage = {
async getItem(key: string): Promise<string | null> {
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<void> {
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<void> {
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);
}
},
};