diff --git a/AGENTS.md b/AGENTS.md index 402b55d..b86663c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,8 @@ This version has breaking changes — APIs, conventions, and file structure may ## Learned Workspace Facts -- 信用盘流水对外类型:`win_credit`(中奖释额)、`bill_settlement`(账期收付),与钱包 `prize`/派彩文案分离。 -- 信用流水结余:`game_settlement_win`/`settlement_confirm`/`bet_hold_release` 会减 `used_credit` 并倒推结余;`settlement_payout`(账期收付)仅记账,`affects_available_credit: false`。 +- 彩票端账号密码登录须图形验证码(后端校验 + 登录页);勿在 SSR 渲染验证码文案(hydration 不一致)。 +- 信用盘流水对外类型:`win_credit`(中奖释额)、`bill_settlement`(账期收付)、`reversal` 含 `game_settlement_reversal`,与钱包 `prize`/派彩文案分离。 +- 信用流水结余:`game_settlement_win`/`settlement_confirm`/`bet_hold_release`/`game_settlement_reversal` 会减 `used_credit` 并倒推结余;`settlement_payout`(账期收付)仅记账,`affects_available_credit: false`;分页 `balance_after` 须跳过前置页累计。 +- 账号密码登录:`lottery-auth.ts` 与 `player-session-store` 须在**模块加载时**从 `sessionStorage` 同步恢复 Bearer(子组件 `useEffect` 早于 `HydratePlayerAuth`,否则刷新 `/hall` 会先 401)。 - Token 进场(`EntryGate`):`entryLifecycleRef`(idle→running→done)防 URL token 剥离或 store 写入后重复触发 `doEntry` 导致成功/失败页闪现;成功后直接 `router.replace('/hall')`。 diff --git a/src/api/player-auth.ts b/src/api/player-auth.ts index 8220c5e..f1a883f 100644 --- a/src/api/player-auth.ts +++ b/src/api/player-auth.ts @@ -1,9 +1,16 @@ import { lotteryRequest } from "@/lib/lottery-http"; +export type PlayerAuthCaptchaResponse = { + captcha_key: string; + image_base64: string; +}; + export type PlayerAuthLoginPayload = { site_code?: string; username: string; password: string; + captcha_key: string; + captcha_code: string; }; export type PlayerAuthLoginData = { @@ -20,6 +27,11 @@ export type PlayerAuthLoginData = { }; }; +/** `GET /api/v1/player/auth/captcha`(公开) */ +export function getPlayerAuthCaptcha(): Promise { + return lotteryRequest.get(`/player/auth/captcha`); +} + /** `POST /api/v1/player/auth/login`(公开) */ export function postPlayerAuthLogin(body: PlayerAuthLoginPayload): Promise { return lotteryRequest.post(`/player/auth/login`, body); diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index 873e0d4..7321397 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -62,6 +62,32 @@ export function HallWalletStrip() { const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0); const isCreditPlayer = isCreditFundingPlayer(balance); + const balanceMinor = Number(balance?.balance ?? 0); + const headlineMinor = isCreditPlayer ? availableMinor : balanceMinor; + const transferInLotteryMinor = isCreditPlayer ? availableMinor : balanceMinor; + // #region agent log + if (typeof window !== "undefined" && balance && !loading) { + fetch("http://127.0.0.1:7696/ingest/e56128e6-898b-4d61-b06d-2aefe0923744", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7fd0fc" }, + body: JSON.stringify({ + sessionId: "7fd0fc", + runId: "hall-wallet-display-post-fix", + hypothesisId: "H3", + location: "hall-wallet-strip.tsx:render", + message: "hall wallet headline amounts", + data: { + isCreditPlayer, + availableMinor, + balanceMinor, + headlineMinor, + mismatchWithWalletPage: !isCreditPlayer && headlineMinor !== balanceMinor, + }, + timestamp: Date.now(), + }), + }).catch(() => {}); + } + // #endregion const mainMinor = balance?.main_balance === null || balance?.main_balance === undefined ? null @@ -102,7 +128,7 @@ export function HallWalletStrip() { ) : (

- {formatMinorAsCurrency(availableMinor, currency)} + {formatMinorAsCurrency(headlineMinor, currency)}

)} @@ -117,7 +143,7 @@ export function HallWalletStrip() { triggerLabel={t("wallet.transferIn")} triggerClassName="h-12 rounded-lg text-base font-bold" currency={currency} - lotteryMinor={availableMinor} + lotteryMinor={transferInLotteryMinor} mainMinor={mainMinor} onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }} /> diff --git a/src/features/player/hydrate-player-auth.tsx b/src/features/player/hydrate-player-auth.tsx index dce17dc..134fffe 100644 --- a/src/features/player/hydrate-player-auth.tsx +++ b/src/features/player/hydrate-player-auth.tsx @@ -8,8 +8,8 @@ import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings"; import { usePlayerSessionStore } from "@/stores/player-session-store"; /** - * 从 sessionStorage 恢复 Bearer,避免 `/hall` 等子路由刷新后丢失鉴权头; - * 若有 Token 无 `profile`,补拉 `GET /player/me` 供顶栏展示。 + * 模块加载时已在 {@link lottery-auth} / store 初始态同步恢复 Bearer; + * 此处再调 `restoreBearerToken` 兜底,并在有 Token 无 `profile` 时补拉 `GET /player/me`。 */ export function HydratePlayerAuth(): null { const restoreBearerToken = usePlayerSessionStore( diff --git a/src/features/player/player-login-screen.tsx b/src/features/player/player-login-screen.tsx index e8075ad..41e21dd 100644 --- a/src/features/player/player-login-screen.tsx +++ b/src/features/player/player-login-screen.tsx @@ -3,12 +3,12 @@ import { Loader2 } from "lucide-react"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { getPlayerMe } from "@/api/player"; -import { postPlayerAuthLogin } from "@/api/player-auth"; +import { getPlayerAuthCaptcha, postPlayerAuthLogin } from "@/api/player-auth"; import { getPublicCurrencies } from "@/api/currency"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,7 @@ import { validatePlayerLoginUsername, } from "@/lib/player-input-validation"; import { LotteryApiBizError } from "@/types/api/errors"; +import "@/i18n"; function stripSearchParamFromBrowserUrl(name: string): void { if (typeof window === "undefined") return; @@ -32,6 +33,10 @@ function stripSearchParamFromBrowserUrl(name: string): void { export function PlayerLoginScreen(): React.ReactElement { const { t } = useTranslation("entry"); + const tRef = useRef(t); + useEffect(() => { + tRef.current = t; + }, [t]); const router = useRouter(); const searchParams = useSearchParams(); const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken); @@ -41,8 +46,43 @@ export function PlayerLoginScreen(): React.ReactElement { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [captchaCode, setCaptchaCode] = useState(""); + const [captchaKey, setCaptchaKey] = useState(null); + const [captchaSrc, setCaptchaSrc] = useState(null); + const [loadingCaptcha, setLoadingCaptcha] = useState(false); const [loading, setLoading] = useState(false); + const loadCaptcha = useCallback(async () => { + setLoadingCaptcha(true); + try { + const data = await getPlayerAuthCaptcha(); + setCaptchaKey(data.captcha_key); + setCaptchaSrc(`data:image/svg+xml;base64,${data.image_base64}`); + setCaptchaCode(""); + } catch { + toast.error(tRef.current("login.captchaLoadFailed")); + setCaptchaKey(null); + setCaptchaSrc(null); + } finally { + setLoadingCaptcha(false); + } + }, []); + + useEffect(() => { + let cancelled = false; + + void (async () => { + if (cancelled) { + return; + } + await loadCaptcha(); + })(); + + return () => { + cancelled = true; + }; + }, [loadCaptcha]); + useEffect(() => { if (sessionExpiredHandled.current) return; if (searchParams.get("session") !== "expired") return; @@ -60,6 +100,17 @@ export function PlayerLoginScreen(): React.ReactElement { return; } + if (!captchaKey || !captchaSrc) { + toast.error(t("login.captchaRequired")); + void loadCaptcha(); + return; + } + + if (!captchaCode.trim()) { + toast.error(t("login.captchaRequired")); + return; + } + const usernameIssue = validatePlayerLoginUsername(username); if (usernameIssue === "invalid_charset") { toast.error( @@ -83,6 +134,8 @@ export function PlayerLoginScreen(): React.ReactElement { const data = await postPlayerAuthLogin({ username: username.trim(), password, + captcha_key: captchaKey, + captcha_code: captchaCode.trim(), }); setBearerToken(data.access_token); const me = await getPlayerMe(); @@ -96,6 +149,7 @@ export function PlayerLoginScreen(): React.ReactElement { router.replace("/hall"); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("login.failed")); + void loadCaptcha(); } finally { setLoading(false); } @@ -128,6 +182,7 @@ export function PlayerLoginScreen(): React.ReactElement { value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" + disabled={loading} />
@@ -138,8 +193,47 @@ export function PlayerLoginScreen(): React.ReactElement { value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" + disabled={loading} />
+
+ +
+ setCaptchaCode(e.target.value)} + placeholder={t("login.captchaPlaceholder")} + maxLength={32} + disabled={loading} + className="min-w-0 flex-1 sm:max-w-[12rem]" + /> + +
+