Files
lotteryFront/src/hooks/use-token-refresh.ts
kang b819894e75 feat: 增强 iframe 通信机制与通知处理功能
实现 resolvePostMessageTargetOrigin,优化 iframe 消息通信中的目标来源(origin)解析与校验。
更新 IframeBridge:支持定期刷新允许的来源列表,并优化消息事件管理机制。
重构 usePendingWalletReconcile:优化待对账通知的获取与缓存逻辑,提升性能与用户体验。
增强 NotificationsScreen:新增待对账通知内容,并优化界面展示效果。
更新英文、尼泊尔语与中文语言包,新增待对账通知相关翻译文案。
2026-06-01 13:38:30 +08:00

211 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 { 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 自动续签 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 refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
const retryCountRef = useRef(0);
/**
* 解析 JWT 的 exp 字段
* @returns exp 时间戳(秒),解析失败返回 null
*/
const parseTokenExp = useCallback((token: string | null): number | null => {
if (!token) return null;
try {
// JWT 格式header.payload.signature
const parts = token.split(".");
if (parts.length !== 3) return null;
// Base64 解码 payload
const payload = JSON.parse(atob(parts[1]));
return payload.exp ?? null;
} catch {
return null;
}
}, []);
/**
* 获取 Token 剩余有效时间
* @returns 剩余毫秒数,-1 表示未知
*/
const getTokenRemainingTime = useCallback((): number => {
const exp = parseTokenExp(bearerToken);
if (!exp) return -1;
const now = Math.floor(Date.now() / 1000);
return Math.max(0, (exp - now) * 1000);
}, [bearerToken, parseTokenExp]);
/**
* 检查 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
window.parent.postMessage(
{
type: "LOTTERY_TOKEN_REFRESH_REQUEST",
timestamp: Date.now(),
},
resolvePostMessageTargetOrigin(),
);
}, []);
/**
* 手动触发 Token 刷新
*/
const refreshToken = useCallback(async (): Promise<void> => {
if (retryCountRef.current >= MAX_RETRY) {
setServerError(true, "Token 刷新失败,请返回主站重新进入");
return;
}
clearServerError();
retryCountRef.current++;
// 向主站请求新 Token
requestParentRefresh();
// 等待主站响应(通过 postMessage
// 实际逻辑在下面的 useEffect 中处理
}, [clearServerError, requestParentRefresh, setServerError]);
/**
* 监听主站 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");
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 = parseTokenExp(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, parseTokenExp, requestParentRefresh]);
return {
refreshToken,
getTokenRemainingTime,
isTokenExpiringSoon,
};
}