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 { Toaster } from "@/components/ui/sonner";
|
||||||
import { ErrorProvider } from "@/components/error-provider";
|
import { ErrorProvider } from "@/components/error-provider";
|
||||||
import { IframeBridge } from "@/components/iframe-bridge";
|
import { IframeBridge } from "@/components/iframe-bridge";
|
||||||
|
import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener";
|
||||||
import { TokenRefreshIndicator } from "@/components/token-refresh-indicator";
|
import { TokenRefreshIndicator } from "@/components/token-refresh-indicator";
|
||||||
import "@/i18n";
|
import "@/i18n";
|
||||||
import { syncPreferredLanguage } from "@/i18n";
|
import { syncPreferredLanguage } from "@/i18n";
|
||||||
@@ -24,6 +25,7 @@ export function Providers({ children }: ProvidersProps): ReactNode {
|
|||||||
{/* iframe 通信桥接 - 支持主站嵌入 */}
|
{/* iframe 通信桥接 - 支持主站嵌入 */}
|
||||||
<IframeBridge>
|
<IframeBridge>
|
||||||
{children}
|
{children}
|
||||||
|
<PlayerBalanceWsListener />
|
||||||
{/* Token 续签指示器 - 显示在右下角 */}
|
{/* Token 续签指示器 - 显示在右下角 */}
|
||||||
<TokenRefreshIndicator />
|
<TokenRefreshIndicator />
|
||||||
</IframeBridge>
|
</IframeBridge>
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ type RiskSoldOutWsEvent = {
|
|||||||
normalized_number?: string;
|
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 CellRiskState = "open" | "warning" | "sold_out";
|
||||||
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
||||||
|
|
||||||
@@ -394,6 +402,7 @@ function cellRiskState(
|
|||||||
category: Exclude<HallCategory, "JACKPOT">,
|
category: Exclude<HallCategory, "JACKPOT">,
|
||||||
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
|
||||||
liveSoldOutNumbers: ReadonlySet<string>,
|
liveSoldOutNumbers: ReadonlySet<string>,
|
||||||
|
liveWarningNumbers: ReadonlySet<string>,
|
||||||
digitSlot?: number,
|
digitSlot?: number,
|
||||||
): CellRiskState {
|
): CellRiskState {
|
||||||
const normalizedRow = rowNumber.trim().toUpperCase();
|
const normalizedRow = rowNumber.trim().toUpperCase();
|
||||||
@@ -403,6 +412,10 @@ function cellRiskState(
|
|||||||
return "sold_out";
|
return "sold_out";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (liveWarningNumbers.has(normalizedRow)) {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
const alerts = alertRows ?? [];
|
const alerts = alertRows ?? [];
|
||||||
if (alerts.length === 0) return "open";
|
if (alerts.length === 0) return "open";
|
||||||
|
|
||||||
@@ -444,6 +457,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
||||||
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
||||||
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
|
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 [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
|
||||||
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
||||||
timer: null,
|
timer: null,
|
||||||
@@ -553,6 +567,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLiveSoldOutNumbers(new Set());
|
setLiveSoldOutNumbers(new Set());
|
||||||
|
setLiveWarningNumbers(new Set());
|
||||||
}, [drawNo]);
|
}, [drawNo]);
|
||||||
|
|
||||||
const alertRows = display?.risk_pool_alerts ?? [];
|
const alertRows = display?.risk_pool_alerts ?? [];
|
||||||
@@ -755,17 +770,37 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
next.add(normalized);
|
next.add(normalized);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setLiveWarningNumbers((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(normalized);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
void reloadDraw();
|
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(".play.toggle", onPlayToggle);
|
||||||
channel.listen(".odds.update", onOddsUpdate);
|
channel.listen(".odds.update", onOddsUpdate);
|
||||||
channel.listen(".risk.sold_out", onRiskSoldOut);
|
channel.listen(".risk.sold_out", onRiskSoldOut);
|
||||||
|
channel.listen(".risk.warning", onRiskWarning);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
channel.stopListening(".play.toggle");
|
channel.stopListening(".play.toggle");
|
||||||
channel.stopListening(".odds.update");
|
channel.stopListening(".odds.update");
|
||||||
channel.stopListening(".risk.sold_out");
|
channel.stopListening(".risk.sold_out");
|
||||||
|
channel.stopListening(".risk.warning");
|
||||||
};
|
};
|
||||||
}, [clearAmountsForPlay, drawNo, loadCatalog, reloadDraw, t]);
|
}, [clearAmountsForPlay, drawNo, loadCatalog, reloadDraw, t]);
|
||||||
|
|
||||||
@@ -1378,6 +1413,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
||||||
alertRows,
|
alertRows,
|
||||||
liveSoldOutNumbers,
|
liveSoldOutNumbers,
|
||||||
|
liveWarningNumbers,
|
||||||
column.digitSlot,
|
column.digitSlot,
|
||||||
);
|
);
|
||||||
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
|
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 { useTokenRefresh } from "./use-token-refresh";
|
||||||
export { useWalletPolling, triggerWalletPollingAfterBet } from "./use-wallet-polling";
|
export { useWalletPolling, triggerWalletPollingAfterBet } from "./use-wallet-polling";
|
||||||
export { useWebSocketManager } from "./use-websocket-manager";
|
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",
|
"totalRecords": "{{total}} records",
|
||||||
"emptyLogs": "No wallet logs",
|
"emptyLogs": "No wallet logs",
|
||||||
"balanceAfter": "Balance after",
|
"balanceAfter": "Balance after",
|
||||||
|
"wsBalanceUpdated": "Balance {{change}} ({{reason}})",
|
||||||
|
"wsReason": {
|
||||||
|
"transferIn": "Transfer in",
|
||||||
|
"transferOut": "Transfer out",
|
||||||
|
"bet": "Bet",
|
||||||
|
"prize": "Prize",
|
||||||
|
"refund": "Refund",
|
||||||
|
"unknown": "Update"
|
||||||
|
},
|
||||||
"flow": {
|
"flow": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"transfer_in": "Transfer in",
|
"transfer_in": "Transfer in",
|
||||||
|
|||||||
@@ -368,6 +368,15 @@
|
|||||||
"totalRecords": "{{total}} रेकर्ड",
|
"totalRecords": "{{total}} रेकर्ड",
|
||||||
"emptyLogs": "वालेट लग छैन",
|
"emptyLogs": "वालेट लग छैन",
|
||||||
"balanceAfter": "पछि बाँकी ब्यालेन्स",
|
"balanceAfter": "पछि बाँकी ब्यालेन्स",
|
||||||
|
"wsBalanceUpdated": "ब्यालेन्स {{change}} ({{reason}})",
|
||||||
|
"wsReason": {
|
||||||
|
"transferIn": "भित्र स्थानान्तरण",
|
||||||
|
"transferOut": "बाहिर स्थानान्तरण",
|
||||||
|
"bet": "बाजी",
|
||||||
|
"prize": "पुरस्कार",
|
||||||
|
"refund": "फिर्ता",
|
||||||
|
"unknown": "अपडेट"
|
||||||
|
},
|
||||||
"flow": {
|
"flow": {
|
||||||
"all": "सबै",
|
"all": "सबै",
|
||||||
"transfer_in": "ट्रान्सफर इन",
|
"transfer_in": "ट्रान्सफर इन",
|
||||||
|
|||||||
@@ -369,6 +369,15 @@
|
|||||||
"totalRecords": "共 {{total}} 条记录",
|
"totalRecords": "共 {{total}} 条记录",
|
||||||
"emptyLogs": "暂无流水",
|
"emptyLogs": "暂无流水",
|
||||||
"balanceAfter": "变更后余额",
|
"balanceAfter": "变更后余额",
|
||||||
|
"wsBalanceUpdated": "余额 {{change}}({{reason}})",
|
||||||
|
"wsReason": {
|
||||||
|
"transferIn": "转入",
|
||||||
|
"transferOut": "转出",
|
||||||
|
"bet": "下注",
|
||||||
|
"prize": "派彩",
|
||||||
|
"refund": "退款",
|
||||||
|
"unknown": "变动"
|
||||||
|
},
|
||||||
"flow": {
|
"flow": {
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"transfer_in": "转入",
|
"transfer_in": "转入",
|
||||||
|
|||||||
Reference in New Issue
Block a user