Files
lotteryFront/src/hooks/use-token-refresh.ts
kang c74bd5f701
Some checks failed
lotteryfront CI / build (push) Has been cancelled
feat(hall): 优化桌面端投注表格布局与国际化文本支持
2026-07-13 10:23:44 +08:00

206 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { parseJwtExp } from "@/lib/jwt-payload";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { useErrorStore } from "@/stores/error-store";
import {
isIframeOriginAllowed,
loadIframeAllowedOrigins,
resolvePostMessageTargetOrigin,
} from "@/lib/iframe-origins";
/** Token 过期前警告阈值(毫秒) */
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
/** 最大重试次数 */
const MAX_RETRY = 3;
/** 等待主站回传新 Token 的超时(毫秒) */
const REFRESH_RESPONSE_TIMEOUT_MS = 5_000;
/**
* Token 自动续签 Hook
*
* 功能:
* 1. 监听主站 postMessage 发送的新 Token
* 2. 自动检测 Token 过期时间并在过期前静默续签
* 3. 支持手动触发刷新
* 4. 刷新失败时提示用户返回主站
*/
export function useTokenRefresh(): {
/** 手动触发 Token 刷新 */
refreshToken: () => Promise<void>;
/** 当前 Token 剩余有效时间(毫秒),-1 表示未知 */
getTokenRemainingTime: () => number;
/** 是否即将过期 */
isTokenExpiringSoon: () => boolean;
} {
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken);
const setServerError = useErrorStore((state) => state.setServerError);
const clearServerError = useErrorStore((state) => state.clearServerError);
const { t } = useTranslation("player");
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
const retryCountRef = useRef(0);
const pendingRefreshRef = useRef(0);
/**
* 获取 Token 剩余有效时间
* @returns 剩余毫秒数,-1 表示未知
*/
const getTokenRemainingTime = useCallback((): number => {
const exp = parseJwtExp(bearerToken);
if (!exp) return -1;
const now = Math.floor(Date.now() / 1000);
return Math.max(0, (exp - now) * 1000);
}, [bearerToken]);
/**
* 检查 Token 是否即将过期1 分钟内)
*/
const isTokenExpiringSoon = useCallback((): boolean => {
const remaining = getTokenRemainingTime();
return remaining > 0 && remaining < TOKEN_WARNING_THRESHOLD;
}, [getTokenRemainingTime]);
/**
* 请求主站刷新 Token
* 通过 postMessage 向父窗口(主站)发送刷新请求
*/
const requestParentRefresh = useCallback((): void => {
if (typeof window === "undefined") return;
// 向主站请求新 Token
const targetOrigin = resolvePostMessageTargetOrigin();
if (!targetOrigin) return;
window.parent.postMessage(
{
type: "LOTTERY_TOKEN_REFRESH_REQUEST",
timestamp: Date.now(),
},
targetOrigin,
);
}, []);
/**
* 手动触发 Token 刷新
*/
const refreshToken = useCallback(async (): Promise<void> => {
if (retryCountRef.current >= MAX_RETRY) {
setServerError(true, t("token.refreshFailed"));
return;
}
clearServerError();
const attemptId = Date.now();
pendingRefreshRef.current = attemptId;
requestParentRefresh();
window.setTimeout(() => {
if (pendingRefreshRef.current !== attemptId) return;
retryCountRef.current += 1;
if (retryCountRef.current >= MAX_RETRY) {
setServerError(true, t("token.refreshFailed"));
}
}, REFRESH_RESPONSE_TIMEOUT_MS);
}, [clearServerError, requestParentRefresh, setServerError, t]);
/**
* 监听主站 postMessage 发送的新 Token
*/
useEffect(() => {
if (typeof window === "undefined") return;
void loadIframeAllowedOrigins();
const handleMessage = async (event: MessageEvent): Promise<void> => {
if (!isIframeOriginAllowed(event.origin)) {
await loadIframeAllowedOrigins(true);
if (!isIframeOriginAllowed(event.origin)) {
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
return;
}
}
const { data } = event;
if (!data || typeof data !== "object") return;
// 处理主站发送的新 Token兼容 MAIN_REFRESH_TOKEN 与 LOTTERY_TOKEN_REFRESH_RESPONSE
if (
(data.type === "LOTTERY_TOKEN_REFRESH_RESPONSE" || data.type === "MAIN_REFRESH_TOKEN") &&
data.token
) {
console.log("[TokenRefresh] Received new token from parent");
pendingRefreshRef.current = 0;
setBearerToken(data.token);
retryCountRef.current = 0;
}
// 处理主站通知 Token 即将过期
if (data.type === "LOTTERY_TOKEN_EXPIRING_WARNING") {
console.log("[TokenRefresh] Token expiring warning from parent");
// 可以在这里显示提示或自动刷新
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [setBearerToken]);
/**
* 自动刷新逻辑
*/
useEffect(() => {
if (!bearerToken) {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
return;
}
const exp = parseJwtExp(bearerToken);
if (!exp) return;
const now = Date.now();
const expMs = exp * 1000;
const remaining = expMs - now;
// 如果已经过期,立即请求刷新
if (remaining <= 0) {
console.warn("[TokenRefresh] Token already expired, requesting refresh");
requestParentRefresh();
return;
}
// 在过期前 30 秒刷新
const refreshDelay = Math.max(0, remaining - TOKEN_WARNING_THRESHOLD);
console.log(
`[TokenRefresh] Token expires in ${Math.floor(remaining / 1000)}s, ` +
`will refresh in ${Math.floor(refreshDelay / 1000)}s`,
);
refreshTimerRef.current = setTimeout(() => {
console.log("[TokenRefresh] Auto-refreshing token");
requestParentRefresh();
}, refreshDelay);
return () => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
};
}, [bearerToken, requestParentRefresh]);
return {
refreshToken,
getTokenRemainingTime,
isTokenExpiringSoon,
};
}