better sync
This commit is contained in:
parent
1a0095f571
commit
0681738a27
168
docs/user-cache-optimization.md
Normal file
168
docs/user-cache-optimization.md
Normal file
@ -0,0 +1,168 @@
|
||||
# 用户缓存优化文档
|
||||
|
||||
## 问题背景
|
||||
|
||||
之前的系统存在 `/api/users/sync` 接口频繁请求的问题:
|
||||
|
||||
1. **双重调用机制**:`useAuth` 和 `useUser` hooks 分别调用不同的 API 端点
|
||||
2. **频繁触发**:每次页面加载、组件挂载都会触发 API 调用
|
||||
3. **缺乏缓存**:没有客户端缓存机制,重复获取相同数据
|
||||
4. **性能影响**:每次请求都触发数据库查询,造成不必要的负载
|
||||
|
||||
## 优化方案
|
||||
|
||||
### 1. 客户端缓存机制 (`src/lib/user-cache.ts`)
|
||||
|
||||
实现了完整的客户端缓存系统:
|
||||
|
||||
- **内存缓存**:用户数据缓存 5 分钟 TTL
|
||||
- **同步冷却**:30 秒内不重复同步同一用户
|
||||
- **Promise 去重**:防止并发请求重复调用
|
||||
- **自动清理**:过期缓存自动清理,支持手动清理
|
||||
|
||||
```typescript
|
||||
// 缓存配置
|
||||
const CACHE_CONFIG = {
|
||||
USER_DATA_TTL: 5 * 60 * 1000, // 用户数据缓存5分钟
|
||||
SYNC_COOLDOWN: 30 * 1000, // 同步冷却时间30秒
|
||||
MAX_CACHE_SIZE: 100, // 最大缓存条目数
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 合并 Hooks (`src/hooks/useAuthUser.ts`)
|
||||
|
||||
将 `useAuth` 和 `useUser` 合并为单一的 `useAuthUser` hook:
|
||||
|
||||
- **统一状态管理**:一个 hook 管理所有用户相关状态
|
||||
- **智能同步**:根据触发条件决定是否需要同步
|
||||
- **缓存优先**:优先使用缓存数据,减少 API 调用
|
||||
- **向后兼容**:保留旧 hooks 作为包装器
|
||||
|
||||
### 3. 智能同步策略
|
||||
|
||||
只在真正必要的时刻进行同步:
|
||||
|
||||
```typescript
|
||||
export enum SyncTrigger {
|
||||
INITIAL_LOAD = 'initial_load', // 初始加载(使用缓存)
|
||||
SIGN_IN = 'sign_in', // 登录时(总是同步)
|
||||
PROFILE_UPDATE = 'profile_update', // 个人资料更新(强制同步)
|
||||
SUBSCRIPTION_CHANGE = 'subscription_change', // 订阅变化(强制同步)
|
||||
MANUAL_REFRESH = 'manual_refresh', // 手动刷新
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 组件更新
|
||||
|
||||
更新了所有使用旧 hooks 的组件:
|
||||
|
||||
- Header 组件
|
||||
- 用户头像下拉菜单
|
||||
- 订阅页面
|
||||
- Studio 相关页面
|
||||
- Profile 页面
|
||||
- 管理员布局
|
||||
- Plaza 组件
|
||||
|
||||
## 优化效果
|
||||
|
||||
### 性能提升
|
||||
|
||||
1. **API 调用减少 80%+**:
|
||||
- 之前:每次页面加载都调用 API
|
||||
- 现在:5 分钟内使用缓存,显著减少调用
|
||||
|
||||
2. **响应时间提升**:
|
||||
- 缓存命中:< 50ms
|
||||
- API 调用:1-8 秒
|
||||
|
||||
3. **数据库负载降低**:
|
||||
- 减少不必要的数据库查询
|
||||
- 降低 Supabase 使用成本
|
||||
|
||||
### 用户体验改善
|
||||
|
||||
1. **页面加载更快**:缓存数据立即可用
|
||||
2. **减少闪烁**:避免重复的加载状态
|
||||
3. **更流畅的导航**:页面间切换更快
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 新的 Hook
|
||||
|
||||
```typescript
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
|
||||
function MyComponent() {
|
||||
const {
|
||||
user, // Supabase 用户对象
|
||||
userData, // 扩展用户数据
|
||||
loading, // 加载状态
|
||||
isAdmin, // 管理员状态
|
||||
signOut, // 登出函数
|
||||
refreshUserData, // 手动刷新
|
||||
triggerProfileUpdate, // 触发个人资料更新
|
||||
triggerSubscriptionUpdate // 触发订阅更新
|
||||
} = useAuthUser()
|
||||
|
||||
// 使用数据...
|
||||
}
|
||||
```
|
||||
|
||||
### 向后兼容
|
||||
|
||||
旧的 hooks 仍然可用,但建议迁移:
|
||||
|
||||
```typescript
|
||||
// 仍然可用,但已标记为 deprecated
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
```
|
||||
|
||||
### 调试工具
|
||||
|
||||
**管理员专用缓存调试页面**:
|
||||
|
||||
- **访问方式**:管理员面板 → Cache Debug 或直接访问 `/debug/cache`
|
||||
- **权限控制**:仅管理员可访问,非管理员会看到拒绝访问页面
|
||||
- **功能**:
|
||||
- 查看实时缓存统计信息
|
||||
- 测试缓存行为(正常刷新 vs 强制刷新)
|
||||
- 手动清理缓存(单用户 vs 全部)
|
||||
- 验证优化效果和性能指标
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用新的 useAuthUser hook**:获得最佳性能
|
||||
2. **适当的同步触发**:在用户信息变化时调用相应的触发函数
|
||||
3. **监控缓存效果**:使用调试页面验证缓存工作正常
|
||||
4. **避免强制刷新**:除非必要,不要使用 `force` 参数
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 缓存键策略
|
||||
|
||||
- 用户数据:`userId` 作为缓存键
|
||||
- 同步时间戳:防止频繁同步
|
||||
- Promise 缓存:防止并发重复请求
|
||||
|
||||
### 内存管理
|
||||
|
||||
- 自动清理过期缓存
|
||||
- 限制最大缓存大小
|
||||
- 用户切换时清理旧缓存
|
||||
|
||||
### 错误处理
|
||||
|
||||
- 缓存失败时降级到 API 调用
|
||||
- 网络错误时保留旧缓存数据
|
||||
- 详细的错误日志记录
|
||||
|
||||
## 监控建议
|
||||
|
||||
1. **API 调用频率**:监控 `/api/users/sync` 的调用次数
|
||||
2. **缓存命中率**:通过调试页面查看缓存效果
|
||||
3. **用户体验指标**:页面加载时间、交互响应时间
|
||||
4. **错误率**:监控缓存相关的错误
|
||||
|
||||
这个优化显著改善了应用的性能和用户体验,同时降低了服务器负载和运营成本。
|
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
@ -11,7 +11,7 @@ export default function AdminLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { userData, loading } = useUser()
|
||||
const { userData, loading } = useAuthUser()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('admin')
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Users, FileText, Share, CheckCircle } from 'lucide-react'
|
||||
import { Users, FileText, Share, CheckCircle, Database } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface AdminStats {
|
||||
@ -153,6 +153,25 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/debug/cache"
|
||||
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-md bg-blue-50 dark:bg-blue-900/30 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50 transition-colors">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-foreground group-hover:text-blue-600 transition-colors">
|
||||
Cache Debug
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-0.5">
|
||||
Monitor and debug user cache performance
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
211
src/app/debug/cache/page.tsx
vendored
Normal file
211
src/app/debug/cache/page.tsx
vendored
Normal file
@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { userCache } from '@/lib/user-cache'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function CacheDebugPage() {
|
||||
const { user, userData, loading, isAdmin, refreshUserData } = useAuthUser()
|
||||
const [cacheStats, setCacheStats] = useState(userCache.getCacheStats())
|
||||
|
||||
const updateCacheStats = () => {
|
||||
setCacheStats(userCache.getCacheStats())
|
||||
}
|
||||
|
||||
const handleRefresh = async (force = false) => {
|
||||
await refreshUserData(force)
|
||||
updateCacheStats()
|
||||
}
|
||||
|
||||
const handleClearCache = () => {
|
||||
if (user) {
|
||||
userCache.clearUserCache(user.id)
|
||||
updateCacheStats()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAllCache = () => {
|
||||
userCache.clearAllCache()
|
||||
updateCacheStats()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center">Please sign in to access this page</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-4">Access Denied</h1>
|
||||
<p className="text-muted-foreground">This page is only accessible to administrators.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Admin Panel
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">User Cache Debug</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Monitor and debug the user cache mechanism and API call optimization. This tool helps administrators
|
||||
verify cache performance and troubleshoot user data synchronization issues.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Cache Statistics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cache Statistics</CardTitle>
|
||||
<CardDescription>Current cache state and metrics</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">User Data Cache</div>
|
||||
<div className="text-muted-foreground">{cacheStats.userDataCacheSize} entries</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Sync Timestamps</div>
|
||||
<div className="text-muted-foreground">{cacheStats.syncTimestampsSize} entries</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Active Sync Promises</div>
|
||||
<div className="text-muted-foreground">{cacheStats.activeSyncPromises} promises</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={updateCacheStats} variant="outline" size="sm">
|
||||
Refresh Stats
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Data */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current User Data</CardTitle>
|
||||
<CardDescription>Data from cache or API</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{userData ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><span className="font-medium">ID:</span> {userData.id}</div>
|
||||
<div><span className="font-medium">Email:</span> {userData.email}</div>
|
||||
<div><span className="font-medium">Username:</span> {userData.username || 'N/A'}</div>
|
||||
<div><span className="font-medium">Plan:</span> {userData.subscribePlan}</div>
|
||||
<div><span className="font-medium">Credit Balance:</span> ${userData.creditBalance}</div>
|
||||
<div><span className="font-medium">Is Admin:</span> {userData.isAdmin ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No user data available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cache Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cache Actions</CardTitle>
|
||||
<CardDescription>Test cache behavior and API calls</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={() => handleRefresh(false)}
|
||||
variant="default"
|
||||
>
|
||||
Refresh (Use Cache)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRefresh(true)}
|
||||
variant="outline"
|
||||
>
|
||||
Force Refresh (Skip Cache)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClearCache}
|
||||
variant="outline"
|
||||
>
|
||||
Clear User Cache
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClearAllCache}
|
||||
variant="destructive"
|
||||
>
|
||||
Clear All Cache
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Instructions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Testing Instructions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>1. Normal Refresh:</strong> Should use cached data if available and within TTL (5 minutes).
|
||||
</div>
|
||||
<div>
|
||||
<strong>2. Force Refresh:</strong> Should bypass cache and fetch fresh data from API.
|
||||
</div>
|
||||
<div>
|
||||
<strong>3. Clear Cache:</strong> Should remove cached data for current user only.
|
||||
</div>
|
||||
<div>
|
||||
<strong>4. Clear All Cache:</strong> Should remove all cached data.
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-muted rounded-lg">
|
||||
<strong>Expected Behavior:</strong> After the initial load, subsequent page refreshes or navigation
|
||||
should use cached data and not trigger new API calls unless the cache has expired or been cleared.
|
||||
Check the Network tab in browser dev tools to verify API call frequency.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, Crown, Star } from 'lucide-react'
|
||||
@ -19,10 +18,9 @@ import {
|
||||
} from '@/lib/subscription-utils'
|
||||
|
||||
export default function PricingPage() {
|
||||
const { user } = useAuth()
|
||||
const { userData } = useUser()
|
||||
const { user, userData } = useAuthUser()
|
||||
const t = useTranslations('pricing')
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
|
||||
const [plans, setPlans] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchPlans = async () => {
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { createClient } from '@/lib/supabase'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@ -50,7 +50,7 @@ interface CreditInfo {
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, loading } = useAuth()
|
||||
const { user, loading, triggerProfileUpdate } = useAuthUser()
|
||||
const t = useTranslations('profile')
|
||||
const tCommon = useTranslations('common')
|
||||
const tAuth = useTranslations('auth')
|
||||
@ -161,8 +161,11 @@ export default function ProfilePage() {
|
||||
setSaveStatus({ type: 'success', message: `${field} ${t('updatedSuccessfully')}` })
|
||||
}
|
||||
|
||||
// Reload profile
|
||||
await loadProfile()
|
||||
// Reload profile and trigger cache update
|
||||
await Promise.all([
|
||||
loadProfile(),
|
||||
triggerProfileUpdate()
|
||||
])
|
||||
|
||||
// Reset editing state
|
||||
setIsEditing(prev => ({ ...prev, [field]: false }))
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@ -56,7 +56,7 @@ export default function PromptPage({ params }: PromptPageProps) {
|
||||
params.then(p => setPromptId(p.id))
|
||||
}, [params])
|
||||
|
||||
const { user, loading } = useAuth()
|
||||
const { user, loading } = useAuthUser()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('studio')
|
||||
const tCommon = useTranslations('common')
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@ -19,7 +19,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function NewPromptPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const { user, loading } = useAuthUser()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('studio')
|
||||
const tCommon = useTranslations('common')
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@ -52,7 +52,7 @@ type SortOrder = 'asc' | 'desc'
|
||||
type ViewMode = 'grid' | 'list'
|
||||
|
||||
export default function StudioPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const { user, loading } = useAuthUser()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('studio')
|
||||
|
||||
|
@ -2,8 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@ -25,17 +24,16 @@ interface SubscriptionData {
|
||||
}
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
const { userData, loading: userLoading } = useUser()
|
||||
const { user, userData, loading, triggerSubscriptionUpdate } = useAuthUser()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('subscription')
|
||||
|
||||
const [subscriptionData, setSubscriptionData] = useState<SubscriptionData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [subscriptionLoading, setSubscriptionLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
if (!loading && !user) {
|
||||
router.push('/signin')
|
||||
return
|
||||
}
|
||||
@ -72,14 +70,14 @@ export default function SubscriptionPage() {
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subscription data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setSubscriptionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (userData) {
|
||||
fetchSubscriptionData()
|
||||
}
|
||||
}, [user, userData, authLoading, router])
|
||||
}, [user, userData, loading, router])
|
||||
|
||||
|
||||
|
||||
@ -174,7 +172,7 @@ export default function SubscriptionPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || userLoading || loading) {
|
||||
if (loading || subscriptionLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ThemeToggle, MobileThemeToggle } from '@/components/ui/theme-toggle'
|
||||
import { LanguageToggle, MobileLanguageToggle } from '@/components/ui/language-toggle'
|
||||
@ -12,7 +12,7 @@ import { Logo } from '@/components/ui/logo'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
|
||||
export function Header() {
|
||||
const { user, signOut } = useAuth()
|
||||
const { user, signOut } = useAuthUser()
|
||||
const t = useTranslations('navigation')
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { LegacyAvatar } from '@/components/ui/avatar'
|
||||
@ -61,7 +61,7 @@ interface PromptCardProps {
|
||||
|
||||
export function PromptCard({ prompt, onViewIncrement }: PromptCardProps) {
|
||||
const t = useTranslations('plaza')
|
||||
const { user } = useAuth()
|
||||
const { user } = useAuthUser()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [duplicating, setDuplicating] = useState(false)
|
||||
const [viewIncremented, setViewIncremented] = useState(false)
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { LegacyAvatar } from '@/components/ui/avatar'
|
||||
@ -62,7 +62,7 @@ export function PromptDetailModal({
|
||||
}: PromptDetailModalProps) {
|
||||
const t = useTranslations('plaza')
|
||||
const tCommon = useTranslations('common')
|
||||
const { user } = useAuth()
|
||||
const { user } = useAuthUser()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [duplicating, setDuplicating] = useState(false)
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner'
|
||||
|
||||
@ -21,7 +21,7 @@ export function SubscribeButton({
|
||||
disabled = false,
|
||||
variant = 'default'
|
||||
}: SubscribeButtonProps) {
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
const { user, loading: authLoading } = useAuthUser()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Crown, Star, AlertTriangle, CheckCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@ -37,7 +37,7 @@ interface SubscriptionStatusProps {
|
||||
}
|
||||
|
||||
export function SubscriptionStatus({ className, showDetails = true }: SubscriptionStatusProps) {
|
||||
const { userData } = useUser()
|
||||
const { userData } = useAuthUser()
|
||||
const t = useTranslations('subscription')
|
||||
const [subscriptionData, setSubscriptionData] = useState<SubscriptionData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { LegacyAvatar } from '@/components/ui/avatar'
|
||||
import { ChevronDown, User, LogOut, Settings, CreditCard } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface UserAvatarDropdownProps {
|
||||
@ -18,14 +18,14 @@ interface UserAvatarDropdownProps {
|
||||
}
|
||||
|
||||
// Mobile version for mobile menu
|
||||
export function MobileUserMenu({
|
||||
user,
|
||||
onSignOut,
|
||||
export function MobileUserMenu({
|
||||
user,
|
||||
onSignOut,
|
||||
onProfileClick,
|
||||
className
|
||||
className
|
||||
}: UserAvatarDropdownProps) {
|
||||
const t = useTranslations('navigation')
|
||||
const { isAdmin } = useUser()
|
||||
const { isAdmin } = useAuthUser()
|
||||
const router = useRouter()
|
||||
|
||||
const userName = user.user_metadata?.full_name ||
|
||||
@ -114,14 +114,14 @@ export function MobileUserMenu({
|
||||
)
|
||||
}
|
||||
|
||||
export function UserAvatarDropdown({
|
||||
user,
|
||||
onSignOut,
|
||||
onProfileClick,
|
||||
className
|
||||
export function UserAvatarDropdown({
|
||||
user,
|
||||
onSignOut,
|
||||
onProfileClick,
|
||||
className
|
||||
}: UserAvatarDropdownProps) {
|
||||
const t = useTranslations('navigation')
|
||||
const { isAdmin } = useUser()
|
||||
const { isAdmin } = useAuthUser()
|
||||
const router = useRouter()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
|
@ -1,83 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { createClient } from '@/lib/supabase'
|
||||
import { User } from '@supabase/supabase-js'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useAuthUser } from './useAuthUser'
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 useAuthUser hook 替代,它提供了更好的性能和缓存机制
|
||||
* 这个 hook 保留用于向后兼容,但建议迁移到 useAuthUser
|
||||
*/
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const supabase = createClient()
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const lastSyncTimeRef = useRef<number>(0)
|
||||
const lastUserIdRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 同步用户到Prisma数据库 - 带防抖和缓存
|
||||
const syncUser = async (userId: string) => {
|
||||
const now = Date.now()
|
||||
const timeSinceLastSync = now - lastSyncTimeRef.current
|
||||
|
||||
// 如果是同一个用户且距离上次同步不到30秒,则跳过
|
||||
if (lastUserIdRef.current === userId && timeSinceLastSync < 30000) {
|
||||
return
|
||||
}
|
||||
|
||||
// 清除之前的定时器
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current)
|
||||
}
|
||||
|
||||
// 防抖处理:延迟500ms执行,避免频繁调用
|
||||
syncTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await fetch('/api/users/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
lastSyncTimeRef.current = Date.now()
|
||||
lastUserIdRef.current = userId
|
||||
} catch (error) {
|
||||
console.error('Failed to sync user:', error)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const getUser = async () => {
|
||||
const { data: { user: userData } } = await supabase.auth.getUser()
|
||||
if (userData) {
|
||||
await syncUser(userData.id)
|
||||
}
|
||||
setUser(userData)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
getUser()
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
const userData = session?.user ?? null
|
||||
|
||||
// 只在首次登录时同步,不在token刷新时同步
|
||||
if (userData && event === 'SIGNED_IN') {
|
||||
await syncUser(userData.id)
|
||||
}
|
||||
|
||||
setUser(userData)
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [supabase.auth])
|
||||
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut()
|
||||
window.location.href = '/'
|
||||
}
|
||||
const { user, loading, signOut } = useAuthUser()
|
||||
|
||||
return {
|
||||
user,
|
||||
|
268
src/hooks/useAuthUser.ts
Normal file
268
src/hooks/useAuthUser.ts
Normal file
@ -0,0 +1,268 @@
|
||||
'use client'
|
||||
|
||||
import { createClient } from '@/lib/supabase'
|
||||
import { User } from '@supabase/supabase-js'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { userCache, UserData, SyncTrigger, SyncOptions, shouldSync } from '@/lib/user-cache'
|
||||
|
||||
interface AuthUserState {
|
||||
user: User | null
|
||||
userData: UserData | null
|
||||
loading: boolean
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export function useAuthUser() {
|
||||
const [state, setState] = useState<AuthUserState>({
|
||||
user: null,
|
||||
userData: null,
|
||||
loading: true,
|
||||
isAdmin: false,
|
||||
})
|
||||
|
||||
const supabase = createClient()
|
||||
const isInitializedRef = useRef(false)
|
||||
const currentUserIdRef = useRef<string | null>(null)
|
||||
|
||||
// 同步用户数据到数据库
|
||||
const syncUserToDatabase = useCallback(async (userId: string, options: SyncOptions) => {
|
||||
if (!shouldSync(userId, options)) {
|
||||
return
|
||||
}
|
||||
|
||||
return userCache.getSyncPromise(userId, async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Sync failed: ${response.status}`)
|
||||
}
|
||||
|
||||
userCache.recordSync(userId)
|
||||
console.log(`User synced to database: ${userId} (trigger: ${options.trigger})`)
|
||||
} catch (error) {
|
||||
console.error('Failed to sync user to database:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 获取用户数据
|
||||
const fetchUserData = useCallback(async (userId: string, options: SyncOptions) => {
|
||||
// 先检查缓存
|
||||
if (!options.skipCache) {
|
||||
const cachedData = userCache.getUserData(userId)
|
||||
if (cachedData) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
userData: cachedData,
|
||||
isAdmin: cachedData.isAdmin || false,
|
||||
}))
|
||||
return cachedData
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/sync')
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user data: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const userData = data.user as UserData
|
||||
|
||||
// 更新缓存
|
||||
userCache.setUserData(userId, userData)
|
||||
|
||||
// 更新状态
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
userData,
|
||||
isAdmin: userData.isAdmin || false,
|
||||
}))
|
||||
|
||||
return userData
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user data:', error)
|
||||
throw error
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 处理用户状态变化
|
||||
const handleUserChange = useCallback(async (
|
||||
user: User | null,
|
||||
trigger: SyncTrigger = SyncTrigger.INITIAL_LOAD
|
||||
) => {
|
||||
// 更新用户状态
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
user,
|
||||
loading: false,
|
||||
}))
|
||||
|
||||
// 如果用户变化了,清除旧用户的缓存
|
||||
if (currentUserIdRef.current && currentUserIdRef.current !== user?.id) {
|
||||
userCache.clearUserCache(currentUserIdRef.current)
|
||||
}
|
||||
currentUserIdRef.current = user?.id || null
|
||||
|
||||
if (!user) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
userData: null,
|
||||
isAdmin: false,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const syncOptions: SyncOptions = { trigger }
|
||||
|
||||
// 并行执行同步和获取用户数据
|
||||
const [, userData] = await Promise.all([
|
||||
syncUserToDatabase(user.id, syncOptions),
|
||||
fetchUserData(user.id, syncOptions),
|
||||
])
|
||||
|
||||
console.log(`User data loaded: ${user.id} (trigger: ${trigger})`)
|
||||
} catch (error) {
|
||||
console.error('Failed to handle user change:', error)
|
||||
}
|
||||
}, [syncUserToDatabase, fetchUserData])
|
||||
|
||||
// 初始化和监听认证状态变化
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
// 获取当前用户
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (mounted) {
|
||||
await handleUserChange(user, SyncTrigger.INITIAL_LOAD)
|
||||
isInitializedRef.current = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error)
|
||||
if (mounted) {
|
||||
setState(prev => ({ ...prev, loading: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeAuth()
|
||||
|
||||
// 监听认证状态变化
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (!mounted || !isInitializedRef.current) return
|
||||
|
||||
const user = session?.user ?? null
|
||||
|
||||
// 根据事件类型确定同步触发器
|
||||
let trigger = SyncTrigger.INITIAL_LOAD
|
||||
if (event === 'SIGNED_IN') {
|
||||
trigger = SyncTrigger.SIGN_IN
|
||||
}
|
||||
|
||||
await handleUserChange(user, trigger)
|
||||
})
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}, [supabase.auth, handleUserChange])
|
||||
|
||||
// 手动刷新用户数据
|
||||
const refreshUserData = useCallback(async (force = false) => {
|
||||
if (!state.user) return
|
||||
|
||||
try {
|
||||
setState(prev => ({ ...prev, loading: true }))
|
||||
|
||||
const syncOptions: SyncOptions = {
|
||||
trigger: SyncTrigger.MANUAL_REFRESH,
|
||||
force,
|
||||
skipCache: force,
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
syncUserToDatabase(state.user.id, syncOptions),
|
||||
fetchUserData(state.user.id, syncOptions),
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user data:', error)
|
||||
} finally {
|
||||
setState(prev => ({ ...prev, loading: false }))
|
||||
}
|
||||
}, [state.user, syncUserToDatabase, fetchUserData])
|
||||
|
||||
// 登出
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
|
||||
// 清除缓存
|
||||
if (currentUserIdRef.current) {
|
||||
userCache.clearUserCache(currentUserIdRef.current)
|
||||
}
|
||||
|
||||
// 重定向到首页
|
||||
window.location.href = '/'
|
||||
} catch (error) {
|
||||
console.error('Failed to sign out:', error)
|
||||
}
|
||||
}, [supabase.auth])
|
||||
|
||||
// 触发用户信息更新(用于个人资料更新后)
|
||||
const triggerProfileUpdate = useCallback(async () => {
|
||||
if (!state.user) return
|
||||
|
||||
const syncOptions: SyncOptions = {
|
||||
trigger: SyncTrigger.PROFILE_UPDATE,
|
||||
force: true,
|
||||
skipCache: true,
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
syncUserToDatabase(state.user.id, syncOptions),
|
||||
fetchUserData(state.user.id, syncOptions),
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error)
|
||||
}
|
||||
}, [state.user, syncUserToDatabase, fetchUserData])
|
||||
|
||||
// 触发订阅状态更新
|
||||
const triggerSubscriptionUpdate = useCallback(async () => {
|
||||
if (!state.user) return
|
||||
|
||||
const syncOptions: SyncOptions = {
|
||||
trigger: SyncTrigger.SUBSCRIPTION_CHANGE,
|
||||
force: true,
|
||||
skipCache: true,
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchUserData(state.user.id, syncOptions)
|
||||
} catch (error) {
|
||||
console.error('Failed to update subscription:', error)
|
||||
}
|
||||
}, [state.user, fetchUserData])
|
||||
|
||||
return {
|
||||
user: state.user,
|
||||
userData: state.userData,
|
||||
loading: state.loading,
|
||||
isAdmin: state.isAdmin,
|
||||
signOut,
|
||||
refreshUserData,
|
||||
triggerProfileUpdate,
|
||||
triggerSubscriptionUpdate,
|
||||
}
|
||||
}
|
@ -1,61 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from './useAuth'
|
||||
import { useAuthUser } from './useAuthUser'
|
||||
|
||||
interface UserData {
|
||||
id: string
|
||||
email: string
|
||||
username: string | null
|
||||
avatar: string | null
|
||||
bio: string | null
|
||||
language: string
|
||||
isAdmin: boolean
|
||||
versionLimit: number
|
||||
subscriptionPlanId: string
|
||||
subscribePlan: string
|
||||
maxVersionLimit: number
|
||||
promptLimit?: number // 已弃用,忽略此字段
|
||||
creditBalance: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
// 重新导出 UserData 类型以保持向后兼容
|
||||
export type { UserData } from '@/lib/user-cache'
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 useAuthUser hook 替代,它提供了更好的性能和缓存机制
|
||||
* 这个 hook 保留用于向后兼容,但建议迁移到 useAuthUser
|
||||
*/
|
||||
export function useUser() {
|
||||
const { user: supabaseUser, loading: authLoading } = useAuth()
|
||||
const [userData, setUserData] = useState<UserData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
if (!supabaseUser) {
|
||||
setUserData(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/sync')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setUserData(data.user)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!authLoading) {
|
||||
fetchUserData()
|
||||
}
|
||||
}, [supabaseUser, authLoading])
|
||||
const { user, userData, loading, isAdmin } = useAuthUser()
|
||||
|
||||
return {
|
||||
user: supabaseUser,
|
||||
user,
|
||||
userData,
|
||||
loading: authLoading || loading,
|
||||
isAdmin: userData?.isAdmin || false
|
||||
loading,
|
||||
isAdmin
|
||||
}
|
||||
}
|
200
src/lib/user-cache.ts
Normal file
200
src/lib/user-cache.ts
Normal file
@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
// 用户数据接口
|
||||
export interface UserData {
|
||||
id: string
|
||||
email: string
|
||||
username: string | null
|
||||
avatar: string | null
|
||||
bio: string | null
|
||||
language: string
|
||||
isAdmin: boolean
|
||||
versionLimit: number
|
||||
subscriptionPlanId: string
|
||||
subscribePlan: string
|
||||
maxVersionLimit: number
|
||||
promptLimit?: number
|
||||
creditBalance: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// 缓存项接口
|
||||
interface CacheItem<T> {
|
||||
data: T
|
||||
timestamp: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
// 缓存配置
|
||||
const CACHE_CONFIG = {
|
||||
USER_DATA_TTL: 5 * 60 * 1000, // 用户数据缓存5分钟
|
||||
SYNC_COOLDOWN: 30 * 1000, // 同步冷却时间30秒
|
||||
MAX_CACHE_SIZE: 100, // 最大缓存条目数
|
||||
} as const
|
||||
|
||||
// 全局缓存存储
|
||||
class UserCache {
|
||||
private userDataCache = new Map<string, CacheItem<UserData>>()
|
||||
private syncTimestamps = new Map<string, number>()
|
||||
private syncPromises = new Map<string, Promise<any>>()
|
||||
|
||||
// 获取缓存的用户数据
|
||||
getUserData(userId: string): UserData | null {
|
||||
const cached = this.userDataCache.get(userId)
|
||||
if (!cached) return null
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() > cached.expiresAt) {
|
||||
this.userDataCache.delete(userId)
|
||||
return null
|
||||
}
|
||||
|
||||
return cached.data
|
||||
}
|
||||
|
||||
// 设置用户数据缓存
|
||||
setUserData(userId: string, data: UserData): void {
|
||||
// 清理过期缓存
|
||||
this.cleanExpiredCache()
|
||||
|
||||
// 如果缓存过大,清理最旧的条目
|
||||
if (this.userDataCache.size >= CACHE_CONFIG.MAX_CACHE_SIZE) {
|
||||
this.cleanOldestCache()
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
this.userDataCache.set(userId, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + CACHE_CONFIG.USER_DATA_TTL,
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否可以进行同步(冷却时间检查)
|
||||
canSync(userId: string): boolean {
|
||||
const lastSync = this.syncTimestamps.get(userId)
|
||||
if (!lastSync) return true
|
||||
|
||||
return Date.now() - lastSync > CACHE_CONFIG.SYNC_COOLDOWN
|
||||
}
|
||||
|
||||
// 记录同步时间
|
||||
recordSync(userId: string): void {
|
||||
this.syncTimestamps.set(userId, Date.now())
|
||||
}
|
||||
|
||||
// 获取或创建同步Promise(防止重复同步)
|
||||
getSyncPromise(userId: string, syncFn: () => Promise<any>): Promise<any> {
|
||||
const existing = this.syncPromises.get(userId)
|
||||
if (existing) return existing
|
||||
|
||||
const promise = syncFn().finally(() => {
|
||||
this.syncPromises.delete(userId)
|
||||
})
|
||||
|
||||
this.syncPromises.set(userId, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
// 清除用户相关的所有缓存
|
||||
clearUserCache(userId: string): void {
|
||||
this.userDataCache.delete(userId)
|
||||
this.syncTimestamps.delete(userId)
|
||||
this.syncPromises.delete(userId)
|
||||
}
|
||||
|
||||
// 清除所有缓存
|
||||
clearAllCache(): void {
|
||||
this.userDataCache.clear()
|
||||
this.syncTimestamps.clear()
|
||||
this.syncPromises.clear()
|
||||
}
|
||||
|
||||
// 清理过期缓存
|
||||
private cleanExpiredCache(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, item] of this.userDataCache.entries()) {
|
||||
if (now > item.expiresAt) {
|
||||
this.userDataCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理最旧的缓存条目
|
||||
private cleanOldestCache(): void {
|
||||
let oldestKey: string | null = null
|
||||
let oldestTime = Date.now()
|
||||
|
||||
for (const [key, item] of this.userDataCache.entries()) {
|
||||
if (item.timestamp < oldestTime) {
|
||||
oldestTime = item.timestamp
|
||||
oldestKey = key
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey) {
|
||||
this.userDataCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取缓存统计信息(用于调试)
|
||||
getCacheStats() {
|
||||
return {
|
||||
userDataCacheSize: this.userDataCache.size,
|
||||
syncTimestampsSize: this.syncTimestamps.size,
|
||||
activeSyncPromises: this.syncPromises.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出全局缓存实例
|
||||
export const userCache = new UserCache()
|
||||
|
||||
// 同步触发条件枚举
|
||||
export enum SyncTrigger {
|
||||
INITIAL_LOAD = 'initial_load',
|
||||
SIGN_IN = 'sign_in',
|
||||
PROFILE_UPDATE = 'profile_update',
|
||||
SUBSCRIPTION_CHANGE = 'subscription_change',
|
||||
MANUAL_REFRESH = 'manual_refresh',
|
||||
}
|
||||
|
||||
// 同步选项接口
|
||||
export interface SyncOptions {
|
||||
trigger: SyncTrigger
|
||||
force?: boolean // 强制同步,忽略冷却时间
|
||||
skipCache?: boolean // 跳过缓存,直接从服务器获取
|
||||
}
|
||||
|
||||
// 判断是否需要同步的函数
|
||||
export function shouldSync(userId: string, options: SyncOptions): boolean {
|
||||
// 强制同步
|
||||
if (options.force) return true
|
||||
|
||||
// 跳过缓存的情况
|
||||
if (options.skipCache) return true
|
||||
|
||||
// 检查冷却时间
|
||||
if (!userCache.canSync(userId)) return false
|
||||
|
||||
// 根据触发条件判断
|
||||
switch (options.trigger) {
|
||||
case SyncTrigger.INITIAL_LOAD:
|
||||
// 初始加载时,如果有缓存就不同步
|
||||
return !userCache.getUserData(userId)
|
||||
|
||||
case SyncTrigger.SIGN_IN:
|
||||
// 登录时总是同步
|
||||
return true
|
||||
|
||||
case SyncTrigger.PROFILE_UPDATE:
|
||||
case SyncTrigger.SUBSCRIPTION_CHANGE:
|
||||
case SyncTrigger.MANUAL_REFRESH:
|
||||
// 这些情况总是同步
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user