412 lines
13 KiB
JavaScript
Executable File
412 lines
13 KiB
JavaScript
Executable File
// src/contexts/AuthContext.js - Session版本
|
||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useToast } from '@chakra-ui/react';
|
||
import { logger } from '../utils/logger';
|
||
import { useNotification } from '../contexts/NotificationContext';
|
||
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
|
||
import { SPECIAL_EVENTS } from '@lib/constants';
|
||
|
||
// 创建认证上下文
|
||
const AuthContext = createContext();
|
||
|
||
// 自定义Hook
|
||
export const useAuth = () => {
|
||
const context = useContext(AuthContext);
|
||
if (!context) {
|
||
throw new Error('useAuth must be used within an AuthProvider');
|
||
}
|
||
return context;
|
||
};
|
||
|
||
// 认证提供者组件
|
||
export const AuthProvider = ({ children }) => {
|
||
const [user, setUser] = useState(null);
|
||
const [isLoading, setIsLoading] = useState(true); // ⚡ 串行执行,阻塞渲染直到 Session 检查完成
|
||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||
const navigate = useNavigate();
|
||
const toast = useToast();
|
||
const { showWelcomeGuide } = useNotification();
|
||
|
||
// ⚡ 使用 ref 保存最新的 isAuthenticated 值,避免事件监听器重复注册
|
||
const isAuthenticatedRef = React.useRef(isAuthenticated);
|
||
|
||
// ⚡ 请求节流:记录上次请求时间,防止短时间内重复请求
|
||
const lastCheckTimeRef = React.useRef(0);
|
||
const MIN_CHECK_INTERVAL = 1000; // 最少间隔1秒
|
||
|
||
// 检查Session状态
|
||
const checkSession = async () => {
|
||
// 节流检查
|
||
const now = Date.now();
|
||
const timeSinceLastCheck = now - lastCheckTimeRef.current;
|
||
|
||
if (timeSinceLastCheck < MIN_CHECK_INTERVAL) {
|
||
logger.warn('AuthContext', 'checkSession 请求被节流(防止频繁请求)', {
|
||
timeSinceLastCheck: `${timeSinceLastCheck}ms`,
|
||
minInterval: `${MIN_CHECK_INTERVAL}ms`,
|
||
reason: '距离上次请求间隔太短'
|
||
});
|
||
return;
|
||
}
|
||
|
||
lastCheckTimeRef.current = now;
|
||
|
||
try {
|
||
logger.debug('AuthContext', '开始检查Session状态', {
|
||
timestamp: new Date().toISOString(),
|
||
timeSinceLastCheck: timeSinceLastCheck > 0 ? `${timeSinceLastCheck}ms` : '首次请求'
|
||
});
|
||
|
||
// 创建超时控制器
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => {
|
||
controller.abort(new Error('Session check timeout after 5 seconds'));
|
||
}, 5000); // 5秒超时
|
||
|
||
const response = await fetch(`/api/auth/session`, {
|
||
method: 'GET',
|
||
credentials: 'include',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
signal: controller.signal
|
||
});
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Session检查失败');
|
||
}
|
||
|
||
const data = await response.json();
|
||
logger.debug('AuthContext', 'Session数据', {
|
||
isAuthenticated: data.isAuthenticated,
|
||
userId: data.user?.id
|
||
});
|
||
|
||
if (data.isAuthenticated && data.user) {
|
||
// ⚡ 只在 user 数据真正变化时才更新状态,避免无限循环
|
||
setUser((prevUser) => {
|
||
// 比较用户 ID,如果相同则不更新
|
||
if (prevUser && prevUser.id === data.user.id) {
|
||
return prevUser;
|
||
}
|
||
|
||
// ✅ 识别用户身份到 PostHog
|
||
identifyUser(data.user.id, {
|
||
email: data.user.email,
|
||
username: data.user.username,
|
||
subscription_tier: data.user.subscription_tier,
|
||
role: data.user.role,
|
||
registration_date: data.user.created_at
|
||
});
|
||
|
||
return data.user;
|
||
});
|
||
setIsAuthenticated((prev) => prev === true ? prev : true);
|
||
} else {
|
||
setUser((prev) => prev === null ? prev : null);
|
||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||
}
|
||
} catch (error) {
|
||
// ✅ 区分AbortError和真实错误
|
||
if (error.name === 'AbortError') {
|
||
logger.debug('AuthContext', 'Session check aborted', {
|
||
reason: error.message || 'Request cancelled',
|
||
isTimeout: error.message?.includes('timeout')
|
||
});
|
||
// AbortError不改变登录状态(保持原状态)
|
||
return;
|
||
}
|
||
|
||
// 只有真实错误才标记为未登录
|
||
logger.error('AuthContext', 'checkSession failed', error);
|
||
setUser((prev) => prev === null ? prev : null);
|
||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||
} finally {
|
||
// ⚡ 只在 isLoading 为 true 时才设置为 false,避免不必要的状态更新
|
||
setIsLoading((prev) => prev === false ? prev : false);
|
||
}
|
||
};
|
||
|
||
// ⚡ 初始化时检查Session - 并行执行,不阻塞页面渲染
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
|
||
// 传递signal给checkSession(需要修改checkSession签名)
|
||
// 暂时使用原有方式,但添加cleanup防止组件卸载时的内存泄漏
|
||
checkSession(); // 直接调用,与页面渲染并行
|
||
|
||
// ✅ Cleanup: 组件卸载时abort可能正在进行的请求
|
||
return () => {
|
||
controller.abort(new Error('AuthProvider unmounted'));
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// ⚡ 同步 isAuthenticated 到 ref
|
||
useEffect(() => {
|
||
isAuthenticatedRef.current = isAuthenticated;
|
||
}, [isAuthenticated]);
|
||
|
||
// 监听路由变化,检查session(处理微信登录回调)
|
||
// ⚡ 移除 isAuthenticated 依赖,使用 ref 避免重复注册事件监听器
|
||
useEffect(() => {
|
||
const handleRouteChange = () => {
|
||
// 使用 ref 获取最新的认证状态
|
||
if (window.location.pathname === '/home' && !isAuthenticatedRef.current) {
|
||
checkSession();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('popstate', handleRouteChange);
|
||
return () => window.removeEventListener('popstate', handleRouteChange);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []); // ✅ 空依赖数组,只注册一次事件监听器
|
||
|
||
// 更新本地用户的便捷方法
|
||
const updateUser = (partial) => {
|
||
setUser((prev) => ({ ...(prev || {}), ...partial }));
|
||
};
|
||
|
||
// 传统登录方法
|
||
const login = async (credential, password, loginType = 'email') => {
|
||
try {
|
||
setIsLoading(true);
|
||
logger.debug('AuthContext', '开始登录流程', { credential: credential.substring(0, 3) + '***', loginType });
|
||
|
||
const formData = new URLSearchParams();
|
||
formData.append('password', password);
|
||
|
||
if (loginType === 'username') {
|
||
formData.append('username', credential);
|
||
} else if (loginType === 'email') {
|
||
formData.append('email', credential);
|
||
} else if (loginType === 'phone') {
|
||
formData.append('username', credential);
|
||
}
|
||
|
||
logger.api.request('POST', '/api/auth/login', {
|
||
credential: credential.substring(0, 3) + '***',
|
||
loginType
|
||
});
|
||
|
||
const response = await fetch(`/api/auth/login`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
credentials: 'include',
|
||
body: formData
|
||
});
|
||
|
||
// 获取响应文本,然后尝试解析JSON
|
||
const responseText = await response.text();
|
||
|
||
let data;
|
||
try {
|
||
data = JSON.parse(responseText);
|
||
logger.api.response('POST', '/api/auth/login', response.status, data);
|
||
} catch (parseError) {
|
||
logger.error('AuthContext', 'login', parseError, { responseText: responseText.substring(0, 100) });
|
||
throw new Error(`服务器响应格式错误: ${responseText.substring(0, 100)}...`);
|
||
}
|
||
|
||
if (!response.ok || !data.success) {
|
||
throw new Error(data.error || '登录失败');
|
||
}
|
||
|
||
// 更新状态
|
||
setUser(data.user);
|
||
setIsAuthenticated(true);
|
||
|
||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||
// 事件名:'User Logged In' 或 'User Signed Up'
|
||
// 属性名:login_method (不是 loginType)
|
||
|
||
// ⚡ 移除toast,让调用者处理UI反馈,避免并发更新冲突
|
||
// toast({
|
||
// title: "登录成功",
|
||
// description: "欢迎回来!",
|
||
// status: "success",
|
||
// duration: 3000,
|
||
// isClosable: true,
|
||
// });
|
||
|
||
// ⚡ 登录成功后显示欢迎引导(延迟2秒,避免与登录Toast冲突)
|
||
setTimeout(() => {
|
||
showWelcomeGuide();
|
||
}, 2000);
|
||
|
||
return { success: true };
|
||
|
||
} catch (error) {
|
||
logger.error('AuthContext', 'login', error, { loginType });
|
||
return { success: false, error: error.message };
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
|
||
// 手机号注册
|
||
const registerWithPhone = async (phone, code, username, password) => {
|
||
try {
|
||
setIsLoading(true);
|
||
|
||
const response = await fetch(`/api/auth/register/phone`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'include',
|
||
body: JSON.stringify({
|
||
phone,
|
||
code,
|
||
username,
|
||
password
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok || !data.success) {
|
||
throw new Error(data.error || '注册失败');
|
||
}
|
||
|
||
// 注册成功后自动登录
|
||
setUser(data.user);
|
||
setIsAuthenticated(true);
|
||
|
||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||
// 事件名:'User Signed Up'(不是 'user_registered')
|
||
// 属性名:login_method(不是 method)
|
||
|
||
toast({
|
||
title: "注册成功",
|
||
description: "欢迎加入价值前沿!",
|
||
status: "success",
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
|
||
// ⚡ 注册成功后显示欢迎引导(延迟2秒)
|
||
setTimeout(() => {
|
||
showWelcomeGuide();
|
||
}, 2000);
|
||
|
||
return { success: true };
|
||
|
||
} catch (error) {
|
||
logger.error('AuthContext', 'registerWithPhone', error, { phone: phone.substring(0, 3) + '****' });
|
||
return { success: false, error: error.message };
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// 发送手机验证码
|
||
const sendSmsCode = async (phone) => {
|
||
try {
|
||
const response = await fetch(`/api/auth/send-sms-code`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'include',
|
||
body: JSON.stringify({ phone })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || '发送失败');
|
||
}
|
||
|
||
// ❌ 移除成功 toast
|
||
logger.info('AuthContext', '验证码已发送', { phone: phone.substring(0, 3) + '****' });
|
||
return { success: true };
|
||
|
||
} catch (error) {
|
||
// ❌ 移除错误 toast
|
||
logger.error('AuthContext', 'sendSmsCode', error, { phone: phone.substring(0, 3) + '****' });
|
||
return { success: false, error: error.message };
|
||
}
|
||
};
|
||
|
||
// 登出方法
|
||
const logout = async () => {
|
||
try {
|
||
// 调用后端登出API
|
||
await fetch(`/api/auth/logout`, {
|
||
method: 'POST',
|
||
credentials: 'include'
|
||
});
|
||
|
||
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份)
|
||
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
|
||
timestamp: new Date().toISOString(),
|
||
user_id: user?.id || null,
|
||
session_duration_minutes: user?.session_start
|
||
? Math.round((Date.now() - new Date(user.session_start).getTime()) / 60000)
|
||
: null,
|
||
});
|
||
|
||
// ✅ 重置 PostHog 用户会话
|
||
resetUser();
|
||
|
||
// 清除本地状态
|
||
setUser(null);
|
||
setIsAuthenticated(false);
|
||
|
||
// ✅ 保留登出成功 toast(关键操作提示)
|
||
toast({
|
||
title: "已登出",
|
||
description: "您已成功退出登录",
|
||
status: "info",
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
|
||
} catch (error) {
|
||
logger.error('AuthContext', 'logout', error);
|
||
// 即使API调用失败也清除本地状态
|
||
setUser(null);
|
||
setIsAuthenticated(false);
|
||
}
|
||
};
|
||
|
||
// 检查用户是否有特定权限
|
||
const hasRole = (role) => {
|
||
return user && user.role === role;
|
||
};
|
||
|
||
// 刷新session(可选)
|
||
const refreshSession = async () => {
|
||
await checkSession();
|
||
};
|
||
|
||
// 提供给子组件的值
|
||
const value = {
|
||
user,
|
||
isAuthenticated,
|
||
isLoading,
|
||
updateUser,
|
||
login,
|
||
registerWithPhone,
|
||
sendSmsCode,
|
||
logout,
|
||
hasRole,
|
||
refreshSession,
|
||
checkSession
|
||
};
|
||
|
||
return (
|
||
<AuthContext.Provider value={value}>
|
||
{children}
|
||
</AuthContext.Provider>
|
||
);
|
||
}; |