206 lines
6.1 KiB
TypeScript
206 lines
6.1 KiB
TypeScript
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,
|
||
};
|
||
}
|