feat: 接入玩家入口与API代理
- 新增 /api 重写代理,支持 LOTTERY_API_PROXY_TARGET 配置 - 玩家首页切换为 EntryGate,并移除 layout 对 PlayerAppShell 的包裹 - 请求层拆分语言头与玩家鉴权注入逻辑,引入 zustand 依赖 - 允许提交 .env.example 供本地配置参考
This commit is contained in:
389
src/features/player/entry-gate.tsx
Normal file
389
src/features/player/entry-gate.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Languages,
|
||||
Loader2,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { getPlayerMe, getPlayerPing } from "@/api/player";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
const MAIN_SITE_URL = process.env.NEXT_PUBLIC_MAIN_SITE_URL?.trim() ?? "";
|
||||
|
||||
const RETRY_ATTEMPTS = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function shouldRetryEntryRequest(error: unknown): boolean {
|
||||
if (error instanceof LotteryApiBizError) {
|
||||
return false;
|
||||
}
|
||||
if (isAxiosError(error)) {
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return true;
|
||||
}
|
||||
if (!error.response) {
|
||||
return true;
|
||||
}
|
||||
const s = error.response.status;
|
||||
return s >= 500 || s === 429;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function withEntryRetries<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let last: unknown;
|
||||
for (let i = 0; i < RETRY_ATTEMPTS; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
last = e;
|
||||
if (!shouldRetryEntryRequest(e) || i === RETRY_ATTEMPTS - 1) {
|
||||
throw e;
|
||||
}
|
||||
await sleep(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
function normalizeTokenInput(raw: string | null): string | null {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const t = decodeURIComponent(raw).trim();
|
||||
return t === "" ? null : t;
|
||||
}
|
||||
|
||||
function stripTokenFromUrl(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has("token")) {
|
||||
return;
|
||||
}
|
||||
url.searchParams.delete("token");
|
||||
const qs = url.searchParams.toString();
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}${qs ? `?${qs}` : ""}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function EntryGate(): ReactNode {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const phase = usePlayerSessionStore((state) => state.phase);
|
||||
const progress = usePlayerSessionStore((state) => state.progress);
|
||||
const errorMessage = usePlayerSessionStore((state) => state.errorMessage);
|
||||
const steps = usePlayerSessionStore((state) => state.steps);
|
||||
const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken);
|
||||
const restoreBearerToken = usePlayerSessionStore(
|
||||
(state) => state.restoreBearerToken,
|
||||
);
|
||||
const clearBearerToken = usePlayerSessionStore(
|
||||
(state) => state.clearBearerToken,
|
||||
);
|
||||
const setProfile = usePlayerSessionStore((state) => state.setProfile);
|
||||
const setPhase = usePlayerSessionStore((state) => state.setPhase);
|
||||
const setProgress = usePlayerSessionStore((state) => state.setProgress);
|
||||
const setErrorMessage = usePlayerSessionStore(
|
||||
(state) => state.setErrorMessage,
|
||||
);
|
||||
const updateStep = usePlayerSessionStore((state) => state.updateStep);
|
||||
const resetEntryFlow = usePlayerSessionStore((state) => state.resetEntryFlow);
|
||||
|
||||
const applyProgress = useCallback(
|
||||
(doneCount: number) => {
|
||||
setProgress(Math.round((doneCount / 3) * 100));
|
||||
},
|
||||
[setProgress],
|
||||
);
|
||||
|
||||
const runBootstrap = useCallback(async () => {
|
||||
resetEntryFlow();
|
||||
|
||||
const fromQuery = normalizeTokenInput(searchParams.get("token"));
|
||||
const fromStorage = normalizeTokenInput(restoreBearerToken());
|
||||
const token = fromQuery ?? fromStorage;
|
||||
|
||||
if (fromQuery) {
|
||||
stripTokenFromUrl();
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setPhase("error");
|
||||
setErrorMessage("缺少登录凭证,请从主站重新进入彩票系统。");
|
||||
updateStep("token", "pending");
|
||||
applyProgress(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setBearerToken(token);
|
||||
|
||||
try {
|
||||
updateStep("token", "done");
|
||||
applyProgress(1);
|
||||
updateStep("account", "active");
|
||||
|
||||
const profile = await withEntryRetries(() => getPlayerMe());
|
||||
setProfile(profile);
|
||||
|
||||
updateStep("account", "done");
|
||||
applyProgress(2);
|
||||
updateStep("hall", "active");
|
||||
|
||||
await withEntryRetries(() => getPlayerPing());
|
||||
|
||||
updateStep("hall", "done");
|
||||
applyProgress(3);
|
||||
setProgress(100);
|
||||
setPhase("success");
|
||||
} catch (e) {
|
||||
const authFailure =
|
||||
e instanceof LotteryApiBizError ||
|
||||
(isAxiosError(e) && e.response?.status === 401);
|
||||
if (authFailure) {
|
||||
clearBearerToken();
|
||||
}
|
||||
setPhase("error");
|
||||
if (e instanceof LotteryApiBizError) {
|
||||
setErrorMessage(
|
||||
e.code === 8001 || e.code === 8002
|
||||
? "授权已失效,请从主站重新进入。"
|
||||
: e.message,
|
||||
);
|
||||
} else if (isAxiosError(e) && !e.response) {
|
||||
setErrorMessage(
|
||||
`网络异常,已重试 ${RETRY_ATTEMPTS} 次仍失败,请稍后再试。`,
|
||||
);
|
||||
} else if (isAxiosError(e)) {
|
||||
setErrorMessage(e.message || "请求失败,请稍后重试。");
|
||||
} else {
|
||||
setErrorMessage(
|
||||
e instanceof Error ? e.message : "进入彩票系统失败,请稍后重试。",
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
applyProgress,
|
||||
clearBearerToken,
|
||||
resetEntryFlow,
|
||||
restoreBearerToken,
|
||||
searchParams,
|
||||
setBearerToken,
|
||||
setErrorMessage,
|
||||
setPhase,
|
||||
setProfile,
|
||||
setProgress,
|
||||
updateStep,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
void runBootstrap();
|
||||
}, [runBootstrap]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-gradient-to-b from-red-800 via-red-900 to-red-950 text-white">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between px-4 sm:h-16">
|
||||
<span className="text-lg font-bold tracking-tight">Lottery</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1 text-white hover:bg-white/10 hover:text-white"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="语言(即将支持)"
|
||||
>
|
||||
<Languages className="size-4 opacity-80" />
|
||||
<span className="text-xs opacity-90">EN</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-white hover:bg-white/10"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="通知(即将支持)"
|
||||
>
|
||||
<Bell className="size-4 opacity-80" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center px-4 py-6 sm:py-8">
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<p className="mb-6 text-center text-sm text-white/85">
|
||||
Play smart. Win more.
|
||||
</p>
|
||||
|
||||
{phase === "loading" || phase === "success" ? (
|
||||
<Card className="border-0 shadow-xl">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-foreground">
|
||||
{phase === "success"
|
||||
? "授权成功"
|
||||
: "正在进入彩票系统"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{phase === "success"
|
||||
? "即将跳转至下注大厅"
|
||||
: "请稍候,正在连接服务器"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<div>
|
||||
<div className="mb-2 flex justify-between text-xs text-muted-foreground">
|
||||
<span>进度</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{steps.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center gap-3 text-sm text-foreground"
|
||||
>
|
||||
{s.status === "done" ? (
|
||||
<Check
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 text-green-600"
|
||||
/>
|
||||
) : s.status === "active" ? (
|
||||
<Loader2
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 animate-spin text-primary"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 rounded-full border border-muted-foreground/40"
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1">{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.status === "done"
|
||||
? "完成"
|
||||
: s.status === "active"
|
||||
? "进行中"
|
||||
: "等待"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
{phase === "success" ? (
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
onClick={() => router.push("/hall")}
|
||||
>
|
||||
进入下注大厅
|
||||
<ChevronRight data-icon="inline-end" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{phase === "error" ? (
|
||||
<Card className="border-destructive/30 bg-destructive/5 shadow-xl">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-destructive/15">
|
||||
<AlertTriangle className="size-7 text-destructive" />
|
||||
</div>
|
||||
<CardTitle className="text-destructive">授权失败</CardTitle>
|
||||
<CardDescription className="text-destructive/90">
|
||||
{errorMessage}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="rounded-lg border border-border bg-card p-3 text-left text-foreground">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
常见原因
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<li>• Token 无效或已过期</li>
|
||||
<li>• 账号未授权或未建档</li>
|
||||
<li>• 会话校验失败</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
{MAIN_SITE_URL ? (
|
||||
<a
|
||||
href={MAIN_SITE_URL}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "destructive" }),
|
||||
"w-full justify-center no-underline",
|
||||
)}
|
||||
>
|
||||
返回主站重新进入
|
||||
</a>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
type="button"
|
||||
disabled
|
||||
title="未配置 NEXT_PUBLIC_MAIN_SITE_URL"
|
||||
>
|
||||
返回主站重新进入
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full bg-transparent text-foreground"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetEntryFlow();
|
||||
void runBootstrap();
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex h-14 shrink-0 items-center justify-center gap-2 px-4 text-xs text-white/70 sm:h-16">
|
||||
<Shield className="size-3.5 shrink-0 opacity-80" />
|
||||
<span>Secure · Trusted · Authorized access</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/features/player/hydrate-player-auth.tsx
Normal file
18
src/features/player/hydrate-player-auth.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
|
||||
/** 从 sessionStorage 恢复 Bearer,避免 `/hall` 等子路由刷新后丢失鉴权头 */
|
||||
export function HydratePlayerAuth(): null {
|
||||
const restoreBearerToken = usePlayerSessionStore(
|
||||
(state) => state.restoreBearerToken,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
restoreBearerToken();
|
||||
}, [restoreBearerToken]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user