feat: 集成玩家余额 WebSocket 监听并增强风控预警处理

新增 PlayerBalanceWsListener,用于处理玩家余额的实时更新。
引入 RiskWarningWsEvent 类型,并更新 HallBettingGrid 以支持实时风控预警处理。
增强 cellRiskState 方法,新增 warning 状态支持,提升风控管理能力。
更新英文、尼泊尔语及中文翻译,新增玩家余额更新相关文案。
This commit is contained in:
2026-05-26 17:13:49 +08:00
parent ab81da3199
commit adae4a0be1
8 changed files with 158 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
"use client";
import type { ReactNode } from "react";
import { usePlayerBalanceWs } from "@/hooks/use-player-balance-ws";
/** 全局挂载:登录后订阅 `balance.update`。 */
export function PlayerBalanceWsListener(): ReactNode {
usePlayerBalanceWs();
return null;
}

View File

@@ -5,6 +5,7 @@ import { useEffect, type ReactNode } from "react";
import { Toaster } from "@/components/ui/sonner";
import { ErrorProvider } from "@/components/error-provider";
import { IframeBridge } from "@/components/iframe-bridge";
import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener";
import { TokenRefreshIndicator } from "@/components/token-refresh-indicator";
import "@/i18n";
import { syncPreferredLanguage } from "@/i18n";
@@ -24,6 +25,7 @@ export function Providers({ children }: ProvidersProps): ReactNode {
{/* iframe 通信桥接 - 支持主站嵌入 */}
<IframeBridge>
{children}
<PlayerBalanceWsListener />
{/* Token 续签指示器 - 显示在右下角 */}
<TokenRefreshIndicator />
</IframeBridge>

View File

@@ -91,6 +91,14 @@ type RiskSoldOutWsEvent = {
normalized_number?: string;
};
type RiskWarningWsEvent = {
draw_id?: number;
draw_no?: string;
normalized_number?: string;
usage_ratio?: number;
usage_percent?: number;
};
type CellRiskState = "open" | "warning" | "sold_out";
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
@@ -394,6 +402,7 @@ function cellRiskState(
category: Exclude<HallCategory, "JACKPOT">,
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
liveSoldOutNumbers: ReadonlySet<string>,
liveWarningNumbers: ReadonlySet<string>,
digitSlot?: number,
): CellRiskState {
const normalizedRow = rowNumber.trim().toUpperCase();
@@ -403,6 +412,10 @@ function cellRiskState(
return "sold_out";
}
if (liveWarningNumbers.has(normalizedRow)) {
return "warning";
}
const alerts = alertRows ?? [];
if (alerts.length === 0) return "open";
@@ -444,6 +457,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
const [liveWarningNumbers, setLiveWarningNumbers] = useState<Set<string>>(() => new Set());
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
timer: null,
@@ -553,6 +567,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
useEffect(() => {
setLiveSoldOutNumbers(new Set());
setLiveWarningNumbers(new Set());
}, [drawNo]);
const alertRows = display?.risk_pool_alerts ?? [];
@@ -755,17 +770,37 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
next.add(normalized);
return next;
});
setLiveWarningNumbers((prev) => {
const next = new Set(prev);
next.delete(normalized);
return next;
});
void reloadDraw();
};
const onRiskWarning = (evt: RiskWarningWsEvent) => {
const normalized = evt.normalized_number?.trim().toUpperCase();
if (!normalized) return;
if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) {
return;
}
setLiveWarningNumbers((prev) => {
const next = new Set(prev);
next.add(normalized);
return next;
});
};
channel.listen(".play.toggle", onPlayToggle);
channel.listen(".odds.update", onOddsUpdate);
channel.listen(".risk.sold_out", onRiskSoldOut);
channel.listen(".risk.warning", onRiskWarning);
return () => {
channel.stopListening(".play.toggle");
channel.stopListening(".odds.update");
channel.stopListening(".risk.sold_out");
channel.stopListening(".risk.warning");
};
}, [clearAmountsForPlay, drawNo, loadCatalog, reloadDraw, t]);
@@ -1378,6 +1413,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
activeCategory as Exclude<HallCategory, "JACKPOT">,
alertRows,
liveSoldOutNumbers,
liveWarningNumbers,
column.digitSlot,
);
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);

View File

@@ -3,3 +3,4 @@ export { useNetworkStatus, useIsOffline } from "./use-network-status";
export { useTokenRefresh } from "./use-token-refresh";
export { useWalletPolling, triggerWalletPollingAfterBet } from "./use-wallet-polling";
export { useWebSocketManager } from "./use-websocket-manager";
export { usePlayerBalanceWs } from "./use-player-balance-ws";

View File

@@ -0,0 +1,80 @@
"use client";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getLotteryEcho } from "@/lib/lottery-echo";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { usePlayerSessionStore } from "@/stores/player-session-store";
type BalanceUpdateWsEvent = {
player_id?: number;
currency_code?: string;
balance_minor?: number;
change_minor?: number;
change_formatted?: string;
reason?: string;
};
const REASON_I18N_KEY: Record<string, string> = {
transfer_in: "wallet.wsReason.transferIn",
transfer_out: "wallet.wsReason.transferOut",
bet: "wallet.wsReason.bet",
prize: "wallet.wsReason.prize",
refund: "wallet.wsReason.refund",
};
/**
* 订阅 `player.{id}` 私有频道的 `balance.update`,刷新余额并 Toast。
*/
export function usePlayerBalanceWs(): void {
const { t } = useTranslation("player");
const playerId = usePlayerSessionStore((state) => state.profile?.id);
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
const { activeCurrency } = useActivePlayerCurrency();
useEffect(() => {
if (!playerId || !bearerToken) {
return;
}
const echo = getLotteryEcho();
if (!echo) {
return;
}
const channelName = `player.${playerId}`;
const channel = echo.channel(channelName);
const onBalanceUpdate = (evt: BalanceUpdateWsEvent): void => {
const currency = evt.currency_code?.trim().toUpperCase();
if (currency && currency !== activeCurrency.toUpperCase()) {
return;
}
window.dispatchEvent(new Event("lottery-wallet-refresh"));
const changeLabel =
typeof evt.change_formatted === "string" && evt.change_formatted !== ""
? evt.change_formatted
: evt.change_minor != null
? String(evt.change_minor)
: "";
const reasonKey =
evt.reason && REASON_I18N_KEY[evt.reason]
? REASON_I18N_KEY[evt.reason]
: "wallet.wsReason.unknown";
const reasonLabel = t(reasonKey);
toast.message(t("wallet.wsBalanceUpdated", { change: changeLabel, reason: reasonLabel }));
};
channel.listen(".balance.update", onBalanceUpdate);
return () => {
channel.stopListening(".balance.update");
};
}, [activeCurrency, bearerToken, playerId, t]);
}

View File

@@ -368,6 +368,15 @@
"totalRecords": "{{total}} records",
"emptyLogs": "No wallet logs",
"balanceAfter": "Balance after",
"wsBalanceUpdated": "Balance {{change}} ({{reason}})",
"wsReason": {
"transferIn": "Transfer in",
"transferOut": "Transfer out",
"bet": "Bet",
"prize": "Prize",
"refund": "Refund",
"unknown": "Update"
},
"flow": {
"all": "All",
"transfer_in": "Transfer in",

View File

@@ -368,6 +368,15 @@
"totalRecords": "{{total}} रेकर्ड",
"emptyLogs": "वालेट लग छैन",
"balanceAfter": "पछि बाँकी ब्यालेन्स",
"wsBalanceUpdated": "ब्यालेन्स {{change}} ({{reason}})",
"wsReason": {
"transferIn": "भित्र स्थानान्तरण",
"transferOut": "बाहिर स्थानान्तरण",
"bet": "बाजी",
"prize": "पुरस्कार",
"refund": "फिर्ता",
"unknown": "अपडेट"
},
"flow": {
"all": "सबै",
"transfer_in": "ट्रान्सफर इन",

View File

@@ -369,6 +369,15 @@
"totalRecords": "共 {{total}} 条记录",
"emptyLogs": "暂无流水",
"balanceAfter": "变更后余额",
"wsBalanceUpdated": "余额 {{change}}{{reason}}",
"wsReason": {
"transferIn": "转入",
"transferOut": "转出",
"bet": "下注",
"prize": "派彩",
"refund": "退款",
"unknown": "变动"
},
"flow": {
"all": "全部",
"transfer_in": "转入",