2026-06-14 18:38:45 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Auth Context — 认证状态管理
|
|
|
|
|
|
// 职责:登录/注册/登出/token 刷新,为子组件提供 auth 状态
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
createContext,
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useContext,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useMemo,
|
|
|
|
|
|
useRef,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
type ReactNode,
|
|
|
|
|
|
} from "react";
|
|
|
|
|
|
import * as api from "./api";
|
|
|
|
|
|
import type { AuthUser } from "./api";
|
2026-06-20 14:57:27 +08:00
|
|
|
|
import { setAuthCallbacks } from "./api";
|
2026-06-14 18:38:45 +08:00
|
|
|
|
import {
|
|
|
|
|
|
clearAuth,
|
|
|
|
|
|
loadAccessToken,
|
|
|
|
|
|
loadRefreshToken,
|
|
|
|
|
|
loadUser,
|
|
|
|
|
|
saveAccessToken,
|
|
|
|
|
|
saveRefreshToken,
|
|
|
|
|
|
saveUser,
|
|
|
|
|
|
} from "./storage";
|
|
|
|
|
|
|
|
|
|
|
|
interface AuthState {
|
|
|
|
|
|
user: AuthUser | null;
|
|
|
|
|
|
isAuthenticated: boolean;
|
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface AuthContextValue extends AuthState {
|
|
|
|
|
|
login: (username: string, password: string) => Promise<{ error?: string }>;
|
|
|
|
|
|
register: (username: string, password: string) => Promise<{ error?: string }>;
|
|
|
|
|
|
logout: () => Promise<void>;
|
|
|
|
|
|
accessToken: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
/** Access token 提前刷新的缓冲时间(秒) */
|
|
|
|
|
|
const REFRESH_BUFFER_SEC = 60;
|
|
|
|
|
|
|
|
|
|
|
|
/** 解析 JWT payload(不做签名验证) */
|
|
|
|
|
|
function parseJwtPayload(token: string): { exp?: number } | null {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const base64 = token.split(".")[1];
|
|
|
|
|
|
const json = atob(base64.replace(/-/g, "+").replace(/_/g, "/"));
|
|
|
|
|
|
return JSON.parse(json);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
|
|
|
const [user, setUser] = useState<AuthUser | null>(loadUser);
|
|
|
|
|
|
const [accessToken, setAccessToken] = useState<string | null>(loadAccessToken);
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 清除定时器
|
|
|
|
|
|
const clearRefreshTimer = useCallback(() => {
|
|
|
|
|
|
if (refreshTimerRef.current) {
|
|
|
|
|
|
clearTimeout(refreshTimerRef.current);
|
|
|
|
|
|
refreshTimerRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 持久化 token + user
|
|
|
|
|
|
const persistAuth = useCallback(
|
|
|
|
|
|
(authUser: AuthUser, access: string, refresh: string) => {
|
|
|
|
|
|
setUser(authUser);
|
|
|
|
|
|
setAccessToken(access);
|
|
|
|
|
|
saveAccessToken(access);
|
|
|
|
|
|
saveRefreshToken(refresh);
|
|
|
|
|
|
saveUser(authUser);
|
|
|
|
|
|
},
|
|
|
|
|
|
[]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 安排自动刷新
|
|
|
|
|
|
const scheduleRefresh = useCallback(
|
|
|
|
|
|
(access: string) => {
|
|
|
|
|
|
clearRefreshTimer();
|
|
|
|
|
|
const payload = parseJwtPayload(access);
|
|
|
|
|
|
if (!payload?.exp) return;
|
|
|
|
|
|
|
|
|
|
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
|
|
|
|
const delayMs = Math.max((payload.exp - nowSec - REFRESH_BUFFER_SEC) * 1000, 5000);
|
|
|
|
|
|
|
|
|
|
|
|
refreshTimerRef.current = setTimeout(async () => {
|
|
|
|
|
|
const rt = loadRefreshToken();
|
|
|
|
|
|
if (!rt) return;
|
|
|
|
|
|
const res = await api.refreshToken(rt);
|
|
|
|
|
|
if (res.data) {
|
|
|
|
|
|
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
|
|
|
|
|
scheduleRefresh(res.data.access_token);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 刷新失败,清除 auth
|
|
|
|
|
|
clearAuth();
|
|
|
|
|
|
setUser(null);
|
|
|
|
|
|
setAccessToken(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, delayMs);
|
|
|
|
|
|
},
|
|
|
|
|
|
[clearRefreshTimer, persistAuth]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-20 14:57:27 +08:00
|
|
|
|
// 注册 API 层认证回调(用于 401 拦截器)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setAuthCallbacks({
|
|
|
|
|
|
getAccessToken: () => loadAccessToken(),
|
|
|
|
|
|
getRefreshToken: () => loadRefreshToken(),
|
|
|
|
|
|
onRefreshSuccess: (u, at, rt) => {
|
2026-06-20 15:07:09 +08:00
|
|
|
|
persistAuth(u, at, rt);
|
|
|
|
|
|
scheduleRefresh(at);
|
2026-06-20 14:57:27 +08:00
|
|
|
|
},
|
|
|
|
|
|
onRefreshFailed: () => {
|
|
|
|
|
|
clearAuth();
|
|
|
|
|
|
setUser(null);
|
|
|
|
|
|
setAccessToken(null);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-06-20 15:07:09 +08:00
|
|
|
|
}, [persistAuth, scheduleRefresh]);
|
2026-06-20 14:57:27 +08:00
|
|
|
|
|
2026-06-14 18:38:45 +08:00
|
|
|
|
// 初始化:检查已有 token 并尝试刷新
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const init = async () => {
|
|
|
|
|
|
const storedAccess = loadAccessToken();
|
|
|
|
|
|
const storedRefresh = loadRefreshToken();
|
|
|
|
|
|
const storedUser = loadUser();
|
|
|
|
|
|
|
|
|
|
|
|
if (!storedAccess || !storedRefresh || !storedUser) {
|
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查 access token 是否过期
|
|
|
|
|
|
const payload = parseJwtPayload(storedAccess);
|
|
|
|
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
|
|
|
|
|
|
|
|
|
|
if (payload?.exp && payload.exp > nowSec) {
|
|
|
|
|
|
// access token 仍然有效
|
|
|
|
|
|
setUser(storedUser);
|
|
|
|
|
|
setAccessToken(storedAccess);
|
|
|
|
|
|
scheduleRefresh(storedAccess);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// access token 过期,尝试 refresh
|
|
|
|
|
|
const res = await api.refreshToken(storedRefresh);
|
|
|
|
|
|
if (res.data) {
|
|
|
|
|
|
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
|
|
|
|
|
scheduleRefresh(res.data.access_token);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearAuth();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
init();
|
|
|
|
|
|
return () => clearRefreshTimer();
|
|
|
|
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
|
|
const login = useCallback(
|
|
|
|
|
|
async (username: string, password: string): Promise<{ error?: string }> => {
|
|
|
|
|
|
const res = await api.login(username, password);
|
|
|
|
|
|
if (res.data) {
|
|
|
|
|
|
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
|
|
|
|
|
scheduleRefresh(res.data.access_token);
|
|
|
|
|
|
return {};
|
|
|
|
|
|
}
|
|
|
|
|
|
return { error: res.error?.message || "登录失败" };
|
|
|
|
|
|
},
|
|
|
|
|
|
[persistAuth, scheduleRefresh]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const register = useCallback(
|
|
|
|
|
|
async (username: string, password: string): Promise<{ error?: string }> => {
|
|
|
|
|
|
const res = await api.register(username, password);
|
|
|
|
|
|
if (res.data) {
|
|
|
|
|
|
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
|
|
|
|
|
scheduleRefresh(res.data.access_token);
|
|
|
|
|
|
return {};
|
|
|
|
|
|
}
|
|
|
|
|
|
return { error: res.error?.message || "注册失败" };
|
|
|
|
|
|
},
|
|
|
|
|
|
[persistAuth, scheduleRefresh]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const logout = useCallback(async () => {
|
|
|
|
|
|
const at = accessToken;
|
|
|
|
|
|
const rt = loadRefreshToken();
|
|
|
|
|
|
if (at && rt) {
|
|
|
|
|
|
await api.logout(at, rt);
|
|
|
|
|
|
}
|
|
|
|
|
|
clearRefreshTimer();
|
|
|
|
|
|
clearAuth();
|
|
|
|
|
|
setUser(null);
|
|
|
|
|
|
setAccessToken(null);
|
|
|
|
|
|
}, [accessToken, clearRefreshTimer]);
|
|
|
|
|
|
|
|
|
|
|
|
const value = useMemo<AuthContextValue>(
|
|
|
|
|
|
() => ({
|
|
|
|
|
|
user,
|
|
|
|
|
|
isAuthenticated: !!user && !!accessToken,
|
|
|
|
|
|
isLoading,
|
|
|
|
|
|
login,
|
|
|
|
|
|
register,
|
|
|
|
|
|
logout,
|
|
|
|
|
|
accessToken,
|
|
|
|
|
|
}),
|
|
|
|
|
|
[user, accessToken, isLoading, login, register, logout]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useAuth(): AuthContextValue {
|
|
|
|
|
|
const ctx = useContext(AuthContext);
|
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
|
throw new Error("useAuth must be used within an AuthProvider");
|
|
|
|
|
|
}
|
|
|
|
|
|
return ctx;
|
|
|
|
|
|
}
|