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