feat: 重构和优化多个组件,增强性能和可维护性
Some checks failed
lotteryfront CI / build (push) Has been cancelled

This commit is contained in:
2026-06-30 11:42:05 +08:00
parent 7bfc713b14
commit ce1aca8905
15 changed files with 269 additions and 92 deletions

View File

@@ -1,4 +1,4 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse } from "next/server";
import { lotteryApiOrigin } from "./src/lib/lottery-api-base"; import { lotteryApiOrigin } from "./src/lib/lottery-api-base";
import { generateCSP, nonCspSecurityHeaders } from "./src/lib/csp-config"; import { generateCSP, nonCspSecurityHeaders } from "./src/lib/csp-config";
@@ -42,7 +42,7 @@ async function loadRuntimeOrigins(): Promise<string[]> {
} }
} }
export async function middleware(_request: NextRequest): Promise<NextResponse> { export async function proxy(): Promise<NextResponse> {
const response = NextResponse.next(); const response = NextResponse.next();
const runtimeOrigins = await loadRuntimeOrigins(); const runtimeOrigins = await loadRuntimeOrigins();

View File

@@ -28,8 +28,6 @@ export const metadata: Metadata = {
export const viewport = { export const viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover", viewportFit: "cover",
}; };

View File

@@ -1,19 +1,39 @@
"use client"; "use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; import {
createContext,
useContext,
useSyncExternalStore,
type ReactNode,
} from "react";
import { syncPreferredLanguage } from "@/i18n"; import { syncPreferredLanguage } from "@/i18n";
const I18nHydrationContext = createContext(false); const I18nHydrationContext = createContext(false);
function subscribeI18nHydration(onStoreChange: () => void): () => void {
queueMicrotask(() => {
syncPreferredLanguage();
onStoreChange();
});
return () => {};
}
function getI18nHydratedSnapshot(): boolean {
return true;
}
function getI18nHydratedServerSnapshot(): boolean {
return false;
}
/** hydration 完成前保持 DEFAULT_LANGUAGE与 SSR 一致;完成后再同步用户偏好语言。 */ /** hydration 完成前保持 DEFAULT_LANGUAGE与 SSR 一致;完成后再同步用户偏好语言。 */
export function I18nHydrationProvider({ children }: { children: ReactNode }) { export function I18nHydrationProvider({ children }: { children: ReactNode }) {
const [hydrated, setHydrated] = useState(false); const hydrated = useSyncExternalStore(
subscribeI18nHydration,
useEffect(() => { getI18nHydratedSnapshot,
syncPreferredLanguage(); getI18nHydratedServerSnapshot,
setHydrated(true); );
}, []);
return ( return (
<I18nHydrationContext.Provider value={hydrated}>{children}</I18nHydrationContext.Provider> <I18nHydrationContext.Provider value={hydrated}>{children}</I18nHydrationContext.Provider>

View File

@@ -10,6 +10,33 @@ import {
resolvePostMessageTargetOrigin, resolvePostMessageTargetOrigin,
} from "@/lib/iframe-origins"; } from "@/lib/iframe-origins";
function sanitizeUrlForParent(href: string): string {
try {
const url = new URL(href);
url.searchParams.delete("token");
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return href;
}
}
function resolveSafeInAppPath(path: string): string | null {
if (!path.startsWith("/") || path.startsWith("//")) {
return null;
}
try {
const url = new URL(path, window.location.origin);
if (url.origin !== window.location.origin) {
return null;
}
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return null;
}
}
/** /**
* iframe 通信桥接组件 * iframe 通信桥接组件
* *
@@ -49,7 +76,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
*/ */
const notifyReady = useCallback((): void => { const notifyReady = useCallback((): void => {
sendToParent("READY", { sendToParent("READY", {
url: window.location.href, url: sanitizeUrlForParent(window.location.href),
userAgent: navigator.userAgent, userAgent: navigator.userAgent,
}); });
}, [sendToParent]); }, [sendToParent]);
@@ -147,7 +174,10 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
// 主站导航请求 // 主站导航请求
case "MAIN_NAVIGATE": case "MAIN_NAVIGATE":
if (data.path && typeof data.path === "string") { if (data.path && typeof data.path === "string") {
window.history.pushState({}, "", data.path); const nextPath = resolveSafeInAppPath(data.path);
if (nextPath) {
window.history.pushState({}, "", nextPath);
}
} }
break; break;

View File

@@ -33,7 +33,7 @@ import {
PLAY_CATALOG_REFRESH_EVENT, PLAY_CATALOG_REFRESH_EVENT,
type PlayCatalogRefreshSource, type PlayCatalogRefreshSource,
} from "@/lib/play-catalog-events"; } from "@/lib/play-catalog-events";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -499,20 +499,28 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const clearPlaceTraceId = useCallback(() => { const clearPlaceTraceId = useCallback(() => {
placeTraceIdRef.current = null; placeTraceIdRef.current = null;
}, []); }, []);
const catalogSeqRef = useRef(0);
const walletSeqRef = useRef(0);
const loadCatalog = useCallback(async () => { const loadCatalog = useCallback(async () => {
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" })); const seq = ++catalogSeqRef.current;
setCatalogState({ kind: "loading" });
try { try {
const data = await getPlayEffective({ currency: currencyParam }); const data = await getPlayEffective({ currency: currencyParam });
if (seq !== catalogSeqRef.current) return;
setCatalogState({ kind: "ok", data }); setCatalogState({ kind: "ok", data });
} catch (e) { } catch (e) {
if (seq !== catalogSeqRef.current) return;
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError"); const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
setCatalogState({ kind: "error", message: msg }); setCatalogState({ kind: "error", message: msg });
} }
}, [currencyParam, t]); }, [currencyParam, t]);
const refreshWallet = useCallback(async () => { const refreshWallet = useCallback(async () => {
const seq = ++walletSeqRef.current;
try { try {
const wallet = await getWalletBalance({ currency: currencyParam }); const wallet = await getWalletBalance({ currency: currencyParam });
if (seq !== walletSeqRef.current) return;
setAvailableMinor(Number(wallet.available_balance ?? 0)); setAvailableMinor(Number(wallet.available_balance ?? 0));
} catch { } catch {
// 保留上次可用余额,避免短暂失败导致误报余额不足 // 保留上次可用余额,避免短暂失败导致误报余额不足
@@ -526,15 +534,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}); });
}, [loadCatalog, refreshWallet]); }, [loadCatalog, refreshWallet]);
useEffect(() => {
const onCurrencyChange = () => {
void loadCatalog();
void refreshWallet();
};
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
}, [loadCatalog, refreshWallet]);
useEffect(() => { useEffect(() => {
const onCatalogRefresh = (ev: Event) => { const onCatalogRefresh = (ev: Event) => {
void loadCatalog(); void loadCatalog();

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -86,23 +86,28 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
const [siblingItems, setSiblingItems] = useState<TicketItemListRow[]>([]); const [siblingItems, setSiblingItems] = useState<TicketItemListRow[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const requestSeqRef = useRef(0);
const backHref = "/orders"; const backHref = "/orders";
const backLabel = t("orders.title"); const backLabel = t("orders.title");
const load = useCallback(async (options?: { silent?: boolean }) => { const load = useCallback(async (options?: { silent?: boolean }) => {
const seq = ++requestSeqRef.current;
if (!options?.silent) { if (!options?.silent) {
setLoading(true); setLoading(true);
} }
setError(null); setError(null);
try { try {
const row = await getTicketItemDetail(ticketNo); const row = await getTicketItemDetail(ticketNo);
if (seq !== requestSeqRef.current) return;
setData(row); setData(row);
} catch { } catch {
if (seq !== requestSeqRef.current) return;
setData(null); setData(null);
if (!options?.silent) { if (!options?.silent) {
setError(t("orders.notFound")); setError(t("orders.notFound"));
} }
} finally { } finally {
if (seq !== requestSeqRef.current) return;
if (!options?.silent) { if (!options?.silent) {
setLoading(false); setLoading(false);
} }
@@ -117,8 +122,14 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
useEffect(() => { useEffect(() => {
const orderNo = (data?.order_no ?? "").trim(); const orderNo = (data?.order_no ?? "").trim();
const siblingSeq = requestSeqRef.current;
if (!orderNo) { if (!orderNo) {
setSiblingItems([]); queueMicrotask(() => {
if (siblingSeq === requestSeqRef.current) {
setSiblingItems([]);
}
});
return; return;
} }
@@ -126,12 +137,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
void (async () => { void (async () => {
try { try {
const res = await getTicketItems({ order_no: orderNo, per_page: 50 }); const res = await getTicketItems({ order_no: orderNo, per_page: 50 });
if (cancelled) return; if (cancelled || siblingSeq !== requestSeqRef.current) return;
setSiblingItems( setSiblingItems(
res.items.filter((row) => row.ticket_no !== ticketNo), res.items.filter((row) => row.ticket_no !== ticketNo),
); );
} catch { } catch {
if (!cancelled) { if (!cancelled && siblingSeq === requestSeqRef.current) {
setSiblingItems([]); setSiblingItems([]);
} }
} }

View File

@@ -19,6 +19,7 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -79,6 +80,29 @@ function initialSteps(): EntryStep[] {
]; ];
} }
let pendingUrlToken = "";
function subscribeUrlTokenCapture(onStoreChange: () => void): () => void {
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
const token = url.searchParams.get("token");
if (token) {
pendingUrlToken = token;
stripSearchParamFromBrowserUrl("token");
queueMicrotask(onStoreChange);
}
}
return () => {};
}
function getUrlTokenSnapshot(): string {
return pendingUrlToken;
}
function getServerUrlTokenSnapshot(): string {
return "";
}
function stripSearchParamFromBrowserUrl(name: string): void { function stripSearchParamFromBrowserUrl(name: string): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
@@ -113,6 +137,11 @@ export function EntryGate() {
const { t: tc } = useTranslation("common"); const { t: tc } = useTranslation("common");
const tokenFromUrl = searchParams.get("token") ?? ""; const tokenFromUrl = searchParams.get("token") ?? "";
const capturedUrlToken = useSyncExternalStore(
subscribeUrlTokenCapture,
getUrlTokenSnapshot,
getServerUrlTokenSnapshot,
);
const sessionExpired = searchParams.get("session") === "expired"; const sessionExpired = searchParams.get("session") === "expired";
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } = const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
@@ -137,11 +166,11 @@ export function EntryGate() {
if (!isInIframe()) { if (!isInIframe()) {
if (sessionExpired) return false; if (sessionExpired) return false;
return tokenFromUrl !== ""; return capturedUrlToken !== "" || tokenFromUrl !== "";
} }
return true; return true;
}, [sessionExpired, tokenFromUrl]); }, [capturedUrlToken, sessionExpired, tokenFromUrl]);
useEffect(() => { useEffect(() => {
if (gateReady) return; if (gateReady) return;
@@ -169,14 +198,15 @@ export function EntryGate() {
const [steps, setSteps] = useState<EntryStep[]>(initialSteps()); const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
const entryToken = useMemo(() => { const entryToken = useMemo(() => {
const urlToken = capturedUrlToken || tokenFromUrl;
if (typeof window === "undefined") { if (typeof window === "undefined") {
return tokenFromUrl || bearerToken; return urlToken || bearerToken;
} }
if (!isInIframe()) { if (!isInIframe()) {
return tokenFromUrl; return urlToken;
} }
return tokenFromUrl || bearerToken; return urlToken || bearerToken;
}, [bearerToken, tokenFromUrl]); }, [bearerToken, capturedUrlToken, tokenFromUrl]);
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */ /** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle"); const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
@@ -215,8 +245,10 @@ export function EntryGate() {
setPhase("loading"); setPhase("loading");
setFailureDetails([]); setFailureDetails([]);
if (tokenFromUrl) { const urlToken = capturedUrlToken || tokenFromUrl;
setBearerToken(tokenFromUrl); if (urlToken) {
setBearerToken(urlToken);
pendingUrlToken = "";
stripSearchParamFromBrowserUrl("token"); stripSearchParamFromBrowserUrl("token");
} }
@@ -311,6 +343,7 @@ export function EntryGate() {
}, },
]); ]);
}, [ }, [
capturedUrlToken,
entryToken, entryToken,
tokenFromUrl, tokenFromUrl,
setBearerToken, setBearerToken,

View File

@@ -63,11 +63,13 @@ export function WalletScreen() {
actionDeepLinkHandledRef.current = true; actionDeepLinkHandledRef.current = true;
if (!isCreditPlayer) { if (!isCreditPlayer) {
if (action === "transfer-in") { queueMicrotask(() => {
setTransferInOpen(true); if (action === "transfer-in") {
} else { setTransferInOpen(true);
setTransferOutOpen(true); } else {
} setTransferOutOpen(true);
}
});
} }
router.replace("/wallet", { scroll: false }); router.replace("/wallet", { scroll: false });
}, [isCreditPlayer, loading, router, searchParams]); }, [isCreditPlayer, loading, router, searchParams]);
@@ -78,7 +80,7 @@ export function WalletScreen() {
if (section !== "logs" && section !== "pending") return; if (section !== "logs" && section !== "pending") return;
sectionDeepLinkHandledRef.current = true; sectionDeepLinkHandledRef.current = true;
const targetId = "wallet-logs"; const targetId = section === "pending" ? "wallet-pending" : "wallet-logs";
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
scrollToWalletSection(targetId); scrollToWalletSection(targetId);
router.replace("/wallet", { scroll: false }); router.replace("/wallet", { scroll: false });
@@ -332,6 +334,42 @@ export function WalletScreen() {
</div> </div>
) : null} ) : null}
{!isCreditPlayer && (logs?.pending_reconcile?.length ?? 0) > 0 ? (
<section
id="wallet-pending"
className="scroll-mt-3 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900"
>
<h2 className="text-sm font-black text-amber-800">
{t("wallet.pendingSectionTitle", { defaultValue: "待对账划转" })}
</h2>
<p className="mt-1 text-xs text-amber-700">
{t("wallet.pendingSectionHint", {
defaultValue: "以下划转仍在与主站对账,请稍后刷新查看结果。",
})}
</p>
<ul className="mt-3 space-y-2">
{logs?.pending_reconcile.map((item) => (
<li
key={item.transfer_no}
className="rounded-xl border border-amber-200 bg-white px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">
{item.direction === "in"
? t("wallet.flow.transfer_in", { defaultValue: "转入" })
: t("wallet.flow.transfer_out", { defaultValue: "转出" })}
</span>
<span className="font-black tabular-nums text-amber-800">
{formatMinorAsCurrency(item.amount, item.currency_code || currency)}
</span>
</div>
<p className="mt-1 font-mono text-[11px] text-amber-600">{item.transfer_no}</p>
</li>
))}
</ul>
</section>
) : null}
<div id="wallet-logs" className="scroll-mt-3"> <div id="wallet-logs" className="scroll-mt-3">
<WalletLogsBlock <WalletLogsBlock
creditMode={isCreditPlayer} creditMode={isCreditPlayer}

View File

@@ -1,32 +1,46 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { getPublicCurrencies } from "@/api/currency"; import { getPublicCurrencies } from "@/api/currency";
import { usePlayerSessionStore } from "@/stores/player-session-store"; import { usePlayerSessionStore } from "@/stores/player-session-store";
const MAX_CURRENCY_LOAD_ATTEMPTS = 3;
const RETRY_DELAY_MS = 2_000;
let inflightCurrencyLoad: Promise<void> | null = null; let inflightCurrencyLoad: Promise<void> | null = null;
export function useCurrencyCatalog() { export function useCurrencyCatalog() {
const currencies = usePlayerSessionStore((state) => state.currencies); const currencies = usePlayerSessionStore((state) => state.currencies);
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies); const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
const [loadGeneration, setLoadGeneration] = useState(0);
useEffect(() => { useEffect(() => {
if (currencies.length > 0 || inflightCurrencyLoad !== null) { if (currencies.length > 0 || loadGeneration >= MAX_CURRENCY_LOAD_ATTEMPTS) {
return;
}
if (inflightCurrencyLoad !== null) {
return; return;
} }
inflightCurrencyLoad = getPublicCurrencies() inflightCurrencyLoad = getPublicCurrencies()
.then((data) => { .then((data) => {
setCurrencies(data.items); if (data.items.length > 0) {
setCurrencies(data.items);
return;
}
throw new Error("empty_currency_catalog");
}) })
.catch(() => { .catch(() => {
// 币种元数据失败时退回默认 2 位小数,不打断主流程。 window.setTimeout(() => {
setLoadGeneration((current) => current + 1);
}, RETRY_DELAY_MS);
}) })
.finally(() => { .finally(() => {
inflightCurrencyLoad = null; inflightCurrencyLoad = null;
}); });
}, [currencies.length, setCurrencies]); }, [currencies.length, setCurrencies, loadGeneration]);
return currencies; return currencies;
} }

View File

@@ -30,22 +30,28 @@ export function usePullToRefresh({
const pullingRef = useRef(false); const pullingRef = useRef(false);
const pullDistanceRef = useRef(0); const pullDistanceRef = useRef(0);
const onRefreshRef = useRef(onRefresh); const onRefreshRef = useRef(onRefresh);
const enabledRef = useRef(enabled);
onRefreshRef.current = onRefresh; useEffect(() => {
onRefreshRef.current = onRefresh;
}, [onRefresh]);
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
const handleTouchStart = useCallback( const handleTouchStart = useCallback(
(e: TouchEvent) => { (e: TouchEvent) => {
if (!enabled || isRefreshing) return; if (!enabledRef.current || isRefreshing) return;
// Check if the scroll container is scrolled down
const scrollContainer = document.getElementById("player-scroll-container"); const scrollContainer = document.getElementById("player-scroll-container");
const currentScrollY = scrollContainer ? scrollContainer.scrollTop : window.scrollY; const currentScrollY = scrollContainer ? scrollContainer.scrollTop : window.scrollY;
if (currentScrollY > 0) return; if (currentScrollY > 0) return;
startYRef.current = e.touches[0].clientY; startYRef.current = e.touches[0].clientY;
pullingRef.current = true; pullingRef.current = true;
}, },
[enabled, isRefreshing], [isRefreshing],
); );
const handleTouchMove = useCallback( const handleTouchMove = useCallback(
@@ -53,7 +59,6 @@ export function usePullToRefresh({
if (!pullingRef.current || isRefreshing) return; if (!pullingRef.current || isRefreshing) return;
const delta = e.touches[0].clientY - startYRef.current; const delta = e.touches[0].clientY - startYRef.current;
if (delta <= 0) { if (delta <= 0) {
// User is scrolling down — abort pull-to-refresh entirely
pullingRef.current = false; pullingRef.current = false;
if (pullDistanceRef.current !== 0) { if (pullDistanceRef.current !== 0) {
pullDistanceRef.current = 0; pullDistanceRef.current = 0;
@@ -61,7 +66,6 @@ export function usePullToRefresh({
} }
return; return;
} }
// Apply rubber-band resistance
const resisted = Math.min(delta * 0.4, threshold * 1.5); const resisted = Math.min(delta * 0.4, threshold * 1.5);
if (resisted !== pullDistanceRef.current) { if (resisted !== pullDistanceRef.current) {
pullDistanceRef.current = resisted; pullDistanceRef.current = resisted;
@@ -72,6 +76,15 @@ export function usePullToRefresh({
); );
const handleTouchEnd = useCallback(async () => { const handleTouchEnd = useCallback(async () => {
if (!enabledRef.current) {
pullingRef.current = false;
if (pullDistanceRef.current !== 0) {
pullDistanceRef.current = 0;
setPullDistance(0);
}
return;
}
if (!pullingRef.current) { if (!pullingRef.current) {
if (pullDistanceRef.current !== 0) { if (pullDistanceRef.current !== 0) {
pullDistanceRef.current = 0; pullDistanceRef.current = 0;
@@ -117,4 +130,4 @@ export function usePullToRefresh({
isRefreshing, isRefreshing,
pullPercent: Math.min(pullDistance / threshold, 1), pullPercent: Math.min(pullDistance / threshold, 1),
}; };
} }

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { parseJwtExp } from "@/lib/jwt-payload";
import { usePlayerSessionStore } from "@/stores/player-session-store"; import { usePlayerSessionStore } from "@/stores/player-session-store";
import { useErrorStore } from "@/stores/error-store"; import { useErrorStore } from "@/stores/error-store";
import { import {
@@ -14,6 +15,9 @@ const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
/** 最大重试次数 */ /** 最大重试次数 */
const MAX_RETRY = 3; const MAX_RETRY = 3;
/** 等待主站回传新 Token 的超时(毫秒) */
const REFRESH_RESPONSE_TIMEOUT_MS = 5_000;
/** /**
* Token 自动续签 Hook * Token 自动续签 Hook
* *
@@ -38,38 +42,19 @@ export function useTokenRefresh(): {
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null); const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
const retryCountRef = useRef(0); const retryCountRef = useRef(0);
const pendingRefreshRef = 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 剩余有效时间 * 获取 Token 剩余有效时间
* @returns 剩余毫秒数,-1 表示未知 * @returns 剩余毫秒数,-1 表示未知
*/ */
const getTokenRemainingTime = useCallback((): number => { const getTokenRemainingTime = useCallback((): number => {
const exp = parseTokenExp(bearerToken); const exp = parseJwtExp(bearerToken);
if (!exp) return -1; if (!exp) return -1;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
return Math.max(0, (exp - now) * 1000); return Math.max(0, (exp - now) * 1000);
}, [bearerToken, parseTokenExp]); }, [bearerToken]);
/** /**
* 检查 Token 是否即将过期1 分钟内) * 检查 Token 是否即将过期1 分钟内)
@@ -109,13 +94,17 @@ export function useTokenRefresh(): {
} }
clearServerError(); clearServerError();
retryCountRef.current++; const attemptId = Date.now();
pendingRefreshRef.current = attemptId;
// 向主站请求新 Token
requestParentRefresh(); requestParentRefresh();
// 等待主站响应(通过 postMessage window.setTimeout(() => {
// 实际逻辑在下面的 useEffect 中处理 if (pendingRefreshRef.current !== attemptId) return;
retryCountRef.current += 1;
if (retryCountRef.current >= MAX_RETRY) {
setServerError(true, "Token 刷新失败,请返回主站重新进入");
}
}, REFRESH_RESPONSE_TIMEOUT_MS);
}, [clearServerError, requestParentRefresh, setServerError]); }, [clearServerError, requestParentRefresh, setServerError]);
/** /**
@@ -144,8 +133,9 @@ export function useTokenRefresh(): {
data.token data.token
) { ) {
console.log("[TokenRefresh] Received new token from parent"); console.log("[TokenRefresh] Received new token from parent");
pendingRefreshRef.current = 0;
setBearerToken(data.token); setBearerToken(data.token);
retryCountRef.current = 0; // 重置重试计数 retryCountRef.current = 0;
} }
// 处理主站通知 Token 即将过期 // 处理主站通知 Token 即将过期
@@ -171,7 +161,7 @@ export function useTokenRefresh(): {
return; return;
} }
const exp = parseTokenExp(bearerToken); const exp = parseJwtExp(bearerToken);
if (!exp) return; if (!exp) return;
const now = Date.now(); const now = Date.now();
@@ -203,7 +193,7 @@ export function useTokenRefresh(): {
clearTimeout(refreshTimerRef.current); clearTimeout(refreshTimerRef.current);
} }
}; };
}, [bearerToken, parseTokenExp, requestParentRefresh]); }, [bearerToken, requestParentRefresh]);
return { return {
refreshToken, refreshToken,

View File

@@ -49,13 +49,17 @@ export function generateCSP(extraParentOrigins: string[] = []): string {
.filter((origin): origin is string => origin !== null), .filter((origin): origin is string => origin !== null),
]), ]),
); );
const scriptSrc =
process.env.NODE_ENV === "production"
? ["'self'", "'unsafe-inline'"]
: ["'self'", "'unsafe-inline'", "'unsafe-eval'"];
const directives: Record<string, string[]> = { const directives: Record<string, string[]> = {
// 默认只允许同源 // 默认只允许同源
"default-src": ["'self'"], "default-src": ["'self'"],
// 脚本允许同源和内联Next.js 需要) // 开发环境保留 unsafe-eval 供 Next 调试;生产环境禁用 eval。
"script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"], "script-src": scriptSrc,
// 样式允许同源和内联 // 样式允许同源和内联
"style-src": ["'self'", "'unsafe-inline'"], "style-src": ["'self'", "'unsafe-inline'"],
@@ -107,7 +111,7 @@ export function generateCSP(extraParentOrigins: string[] = []): string {
export function isAllowedParent(parentOrigin: string): boolean { export function isAllowedParent(parentOrigin: string): boolean {
const origins = staticAllowedParentOrigins(); const origins = staticAllowedParentOrigins();
if (origins.length === 0) return false; if (origins.length === 0) return false;
return origins.some((origin) => parentOrigin.startsWith(origin)); return origins.includes(parentOrigin);
} }
/** /**

23
src/lib/jwt-payload.ts Normal file
View File

@@ -0,0 +1,23 @@
/** 解析 JWT payloadRFC 7519 base64url。 */
export function parseJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
try {
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const json = atob(padded);
const payload = JSON.parse(json) as Record<string, unknown>;
return payload;
} catch {
return null;
}
}
/** 返回 JWT `exp`(秒级 Unix 时间戳),解析失败返回 null。 */
export function parseJwtExp(token: string | null): number | null {
if (!token) return null;
const payload = parseJwtPayload(token);
const exp = payload?.exp;
return typeof exp === "number" ? exp : null;
}

View File

@@ -50,7 +50,6 @@ export async function proxyLotteryApi(
return NextResponse.json( return NextResponse.json(
{ {
msg: "Upstream Laravel unreachable", msg: "Upstream Laravel unreachable",
target,
error: error instanceof Error ? error.message : "Unknown error", error: error instanceof Error ? error.message : "Unknown error",
}, },
{ status: 502 }, { status: 502 },

View File

@@ -61,7 +61,12 @@ function shouldRedirectPlayerSessionExpired(error: AxiosError): boolean {
/** 站内接口 401清本地会话并回入口与 {@link EntryGate} `session=expired` 衔接 */ /** 站内接口 401清本地会话并回入口与 {@link EntryGate} `session=expired` 衔接 */
/** 500 错误:更新全局服务器错误状态 */ /** 500 错误:更新全局服务器错误状态 */
lotteryHttp.interceptors.response.use( lotteryHttp.interceptors.response.use(
(response) => response, (response) => {
if (typeof window !== "undefined") {
useErrorStore.getState().setIsOffline(false);
}
return response;
},
(error: unknown) => { (error: unknown) => {
if (isAxiosError(error) && typeof window !== "undefined") { if (isAxiosError(error) && typeof window !== "undefined") {
const status = error.response?.status; const status = error.response?.status;