feat: 集成玩家余额 WebSocket 监听并增强风控预警处理
新增 PlayerBalanceWsListener,用于处理玩家余额的实时更新。 引入 RiskWarningWsEvent 类型,并更新 HallBettingGrid 以支持实时风控预警处理。 增强 cellRiskState 方法,新增 warning 状态支持,提升风控管理能力。 更新英文、尼泊尔语及中文翻译,新增玩家余额更新相关文案。
This commit is contained in:
12
src/components/player-balance-ws-listener.tsx
Normal file
12
src/components/player-balance-ws-listener.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
80
src/hooks/use-player-balance-ws.ts
Normal file
80
src/hooks/use-player-balance-ws.ts
Normal 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]);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -368,6 +368,15 @@
|
||||
"totalRecords": "{{total}} रेकर्ड",
|
||||
"emptyLogs": "वालेट लग छैन",
|
||||
"balanceAfter": "पछि बाँकी ब्यालेन्स",
|
||||
"wsBalanceUpdated": "ब्यालेन्स {{change}} ({{reason}})",
|
||||
"wsReason": {
|
||||
"transferIn": "भित्र स्थानान्तरण",
|
||||
"transferOut": "बाहिर स्थानान्तरण",
|
||||
"bet": "बाजी",
|
||||
"prize": "पुरस्कार",
|
||||
"refund": "फिर्ता",
|
||||
"unknown": "अपडेट"
|
||||
},
|
||||
"flow": {
|
||||
"all": "सबै",
|
||||
"transfer_in": "ट्रान्सफर इन",
|
||||
|
||||
@@ -369,6 +369,15 @@
|
||||
"totalRecords": "共 {{total}} 条记录",
|
||||
"emptyLogs": "暂无流水",
|
||||
"balanceAfter": "变更后余额",
|
||||
"wsBalanceUpdated": "余额 {{change}}({{reason}})",
|
||||
"wsReason": {
|
||||
"transferIn": "转入",
|
||||
"transferOut": "转出",
|
||||
"bet": "下注",
|
||||
"prize": "派彩",
|
||||
"refund": "退款",
|
||||
"unknown": "变动"
|
||||
},
|
||||
"flow": {
|
||||
"all": "全部",
|
||||
"transfer_in": "转入",
|
||||
|
||||
Reference in New Issue
Block a user