440 lines
12 KiB
JavaScript
Executable File
440 lines
12 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';
|
||
|
||
// 创建认证上下文
|
||
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(), 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;
|
||
}
|
||
return data.user;
|
||
});
|
||
setIsAuthenticated((prev) => prev === true ? prev : true);
|
||
} else {
|
||
setUser((prev) => prev === null ? prev : null);
|
||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||
}
|
||
} catch (error) {
|
||
logger.error('AuthContext', 'checkSession', 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(() => {
|
||
checkSession(); // 直接调用,与页面渲染并行
|
||
// 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);
|
||
|
||
// ⚡ 移除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);
|
||
|
||
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 registerWithEmail = async (email, code, username, password) => {
|
||
try {
|
||
setIsLoading(true);
|
||
|
||
const response = await fetch(`/api/auth/register/email`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'include',
|
||
body: JSON.stringify({
|
||
email,
|
||
code,
|
||
username,
|
||
password
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok || !data.success) {
|
||
throw new Error(data.error || '注册失败');
|
||
}
|
||
|
||
// 注册成功后自动登录
|
||
setUser(data.user);
|
||
setIsAuthenticated(true);
|
||
|
||
toast({
|
||
title: "注册成功",
|
||
description: "欢迎加入价值前沿!",
|
||
status: "success",
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
|
||
// ⚡ 注册成功后显示欢迎引导(延迟2秒)
|
||
setTimeout(() => {
|
||
showWelcomeGuide();
|
||
}, 2000);
|
||
|
||
return { success: true };
|
||
|
||
} catch (error) {
|
||
logger.error('AuthContext', 'registerWithEmail', error);
|
||
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 sendEmailCode = async (email) => {
|
||
try {
|
||
const response = await fetch(`/api/auth/send-email-code`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'include',
|
||
body: JSON.stringify({ email })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || '发送失败');
|
||
}
|
||
|
||
// ❌ 移除成功 toast
|
||
logger.info('AuthContext', '邮箱验证码已发送', { email: email.substring(0, 3) + '***@***' });
|
||
return { success: true };
|
||
|
||
} catch (error) {
|
||
// ❌ 移除错误 toast
|
||
logger.error('AuthContext', 'sendEmailCode', error);
|
||
return { success: false, error: error.message };
|
||
}
|
||
};
|
||
|
||
// 登出方法
|
||
const logout = async () => {
|
||
try {
|
||
// 调用后端登出API
|
||
await fetch(`/api/auth/logout`, {
|
||
method: 'POST',
|
||
credentials: 'include'
|
||
});
|
||
|
||
// 清除本地状态
|
||
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,
|
||
registerWithEmail,
|
||
sendSmsCode,
|
||
sendEmailCode,
|
||
logout,
|
||
hasRole,
|
||
refreshSession,
|
||
checkSession
|
||
};
|
||
|
||
return (
|
||
<AuthContext.Provider value={value}>
|
||
{children}
|
||
</AuthContext.Provider>
|
||
);
|
||
}; |