fix(hall): freeze ticket preview submissions

This commit is contained in:
wchino
2026-07-21 23:41:18 +08:00
parent 912cd01383
commit 9b3eae1896

View File

@@ -184,6 +184,14 @@ type ClosedPlayCleanupData = {
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>; cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
}; };
type TicketPreviewSubmissionSnapshot = {
drawId: string;
currencyCode: string;
clientTraceId: string;
lines: TicketLineInput[];
expectedConfigVersions: TicketPreviewData["config_versions"];
};
type PlayToggleWsEvent = { type PlayToggleWsEvent = {
play_code?: string; play_code?: string;
enabled?: boolean; enabled?: boolean;
@@ -601,7 +609,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const [syncAmountColumns, setSyncAmountColumns] = useState<Record<string, boolean>>({}); const [syncAmountColumns, setSyncAmountColumns] = useState<Record<string, boolean>>({});
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState()); const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
const [quickFillExpanded, setQuickFillExpanded] = useState(false); const [quickFillExpanded, setQuickFillExpanded] = useState(false);
const [riskStateDrawNo, setRiskStateDrawNo] = useState<string | null>(display?.draw_no ?? null);
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 [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 });
@@ -615,6 +622,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}); });
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */ /** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
const placeTraceIdRef = useRef<string | null>(null); const placeTraceIdRef = useRef<string | null>(null);
const previewRequestSeqRef = useRef(0);
const previewSubmissionRef = useRef<TicketPreviewSubmissionSnapshot | null>(null);
const newPlaceTraceId = (): string => const newPlaceTraceId = (): string =>
typeof crypto !== "undefined" && crypto.randomUUID typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID() ? crypto.randomUUID()
@@ -623,6 +632,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const clearPlaceTraceId = useCallback(() => { const clearPlaceTraceId = useCallback(() => {
placeTraceIdRef.current = null; placeTraceIdRef.current = null;
}, []); }, []);
const invalidatePreview = useCallback(() => {
previewRequestSeqRef.current += 1;
previewSubmissionRef.current = null;
setPreviewOpen(false);
setPreviewData(null);
clearPlaceTraceId();
}, [clearPlaceTraceId]);
const catalogSeqRef = useRef(0); const catalogSeqRef = useRef(0);
const walletSeqRef = useRef(0); const walletSeqRef = useRef(0);
const selectedCatalogProviderCode = const selectedCatalogProviderCode =
@@ -697,16 +713,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
void loadCatalog(); void loadCatalog();
const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail
?.source; ?.source;
if (source === "play_config" || source === "risk_cap") { if (source !== undefined) {
setPreviewOpen(false); invalidatePreview();
setPreviewData(null);
clearPlaceTraceId();
toast.message(t("hall.playConfig.updated")); toast.message(t("hall.playConfig.updated"));
} }
}; };
window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
}, [clearPlaceTraceId, loadCatalog, t]); }, [invalidatePreview, loadCatalog, t]);
const openPlays = useMemo(() => { const openPlays = useMemo(() => {
if (catalogState.kind !== "ok") return []; if (catalogState.kind !== "ok") return [];
@@ -801,11 +815,19 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const drawNo = display?.draw_no ?? null; const drawNo = display?.draw_no ?? null;
if (riskStateDrawNo !== drawNo) { useEffect(() => {
setRiskStateDrawNo(drawNo); let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setLiveSoldOutNumbers(new Set()); setLiveSoldOutNumbers(new Set());
setLiveWarningNumbers(new Set()); setLiveWarningNumbers(new Set());
} invalidatePreview();
});
return () => {
cancelled = true;
};
}, [currencyCode, drawNo, invalidatePreview]);
const alertRows = useMemo( const alertRows = useMemo(
() => display?.risk_pool_alerts ?? [], () => display?.risk_pool_alerts ?? [],
@@ -1169,9 +1191,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
if (evt.enabled === false && typeof evt.play_code === "string") { if (evt.enabled === false && typeof evt.play_code === "string") {
const removed = hasAmountsForPlay(evt.play_code); const removed = hasAmountsForPlay(evt.play_code);
clearAmountsForPlay(evt.play_code); clearAmountsForPlay(evt.play_code);
setPreviewOpen(false); invalidatePreview();
setPreviewData(null);
clearPlaceTraceId();
toast.warning( toast.warning(
removed removed
? t("hall.playConfig.playClosedDraftCleared", { ? t("hall.playConfig.playClosedDraftCleared", {
@@ -1185,9 +1205,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}; };
const onOddsUpdate = (evt: OddsUpdateWsEvent) => { const onOddsUpdate = (evt: OddsUpdateWsEvent) => {
setPreviewOpen(false); invalidatePreview();
setPreviewData(null);
clearPlaceTraceId();
toast.message(evt.message ?? t("hall.playConfig.oddsUpdated")); toast.message(evt.message ?? t("hall.playConfig.oddsUpdated"));
}; };
@@ -1207,6 +1225,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
next.delete(normalized); next.delete(normalized);
return next; return next;
}); });
invalidatePreview();
void reloadDraw(); void reloadDraw();
}; };
@@ -1234,7 +1253,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
channel.stopListening(".risk.sold_out"); channel.stopListening(".risk.sold_out");
channel.stopListening(".risk.warning"); channel.stopListening(".risk.warning");
}; };
}, [clearAmountsForPlay, clearPlaceTraceId, drawNo, hasAmountsForPlay, reloadDraw, t]); }, [clearAmountsForPlay, drawNo, hasAmountsForPlay, invalidatePreview, reloadDraw, t]);
const collectDraftLineIssues = useCallback((): DraftLineIssue[] => { const collectDraftLineIssues = useCallback((): DraftLineIssue[] => {
const issues: DraftLineIssue[] = []; const issues: DraftLineIssue[] = [];
@@ -1471,14 +1490,30 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
} }
setPreviewLoading(true); setPreviewLoading(true);
const requestSeq = ++previewRequestSeqRef.current;
const traceId = newPlaceTraceId();
const drawId = display.draw_no;
const previewCurrencyCode = currencyCode;
const frozenLines = lines.map((line) => ({
...line,
provider_codes: line.provider_codes ? [...line.provider_codes] : undefined,
}));
placeTraceIdRef.current = traceId;
try { try {
placeTraceIdRef.current = newPlaceTraceId();
const data = await postTicketPreview({ const data = await postTicketPreview({
draw_id: display.draw_no, draw_id: drawId,
currency_code: currencyCode, currency_code: previewCurrencyCode,
client_trace_id: placeTraceIdRef.current, client_trace_id: traceId,
lines, lines: frozenLines,
}); });
if (requestSeq !== previewRequestSeqRef.current) return;
previewSubmissionRef.current = {
drawId: data.draw.draw_id.trim() || drawId,
currencyCode: previewCurrencyCode,
clientTraceId: traceId,
lines: frozenLines,
expectedConfigVersions: data.config_versions,
};
setPreviewData(data); setPreviewData(data);
setPreviewOpen(true); setPreviewOpen(true);
rows.forEach((row) => { rows.forEach((row) => {
@@ -1493,9 +1528,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return; return;
} }
if (e instanceof LotteryApiBizError && code === 2008) { if (e instanceof LotteryApiBizError && code === 2008) {
setPreviewOpen(false); invalidatePreview();
setPreviewData(null);
clearPlaceTraceId();
} }
if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) { if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
void reloadDraw(); void reloadDraw();
@@ -1507,7 +1540,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}; };
const handlePlace = async () => { const handlePlace = async () => {
if (!display || !previewData) return; if (!previewData) return;
if (placeLoading) { if (placeLoading) {
return; return;
} }
@@ -1516,29 +1549,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
return; return;
} }
const lineIssues = collectDraftLineIssues(); const snapshot = previewSubmissionRef.current;
const missingProviderAt = providerMissingRow(); if (snapshot === null) {
if (missingProviderAt !== null) {
toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `${missingProviderAt} 行请选择至少一个开注商` }));
return;
}
if (lineIssues.length > 0) {
toast.error(formatDraftLineIssue(lineIssues[0]));
return;
}
const lines = buildLines();
if (lines.length === 0) {
toast.error(t("hall.changedBeforeSubmit")); toast.error(t("hall.changedBeforeSubmit"));
return; return;
} }
const traceId = placeTraceIdRef.current ?? newPlaceTraceId(); if (snapshot.drawId === "") {
placeTraceIdRef.current = traceId;
const drawIdForBet =
previewData.draw.draw_id.trim() || display.draw_no.trim();
if (drawIdForBet === "") {
toast.error(t("hall.notBettable")); toast.error(t("hall.notBettable"));
return; return;
} }
@@ -1546,12 +1563,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
setPlaceLoading(true); setPlaceLoading(true);
try { try {
const data = await postTicketPlace({ const data = await postTicketPlace({
draw_id: drawIdForBet, draw_id: snapshot.drawId,
currency_code: currencyCode, currency_code: snapshot.currencyCode,
client_trace_id: traceId, client_trace_id: snapshot.clientTraceId,
lines, lines: snapshot.lines,
expected_config_versions: previewData.config_versions, expected_config_versions: snapshot.expectedConfigVersions,
}); });
previewSubmissionRef.current = null;
clearPlaceTraceId(); clearPlaceTraceId();
setPreviewOpen(false); setPreviewOpen(false);
setPreviewData(null); setPreviewData(null);
@@ -1591,15 +1609,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) { if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) {
const payload = e.data as ClosedPlayCleanupData; const payload = e.data as ClosedPlayCleanupData;
toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002")); toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002"));
setPreviewOpen(false); invalidatePreview();
setPreviewData(null);
clearPlaceTraceId();
return; return;
} }
if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) { if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) {
setPreviewOpen(false); invalidatePreview();
setPreviewData(null);
clearPlaceTraceId();
} }
if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) { if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
void reloadDraw(); void reloadDraw();
@@ -2422,6 +2436,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
if (!open) { if (!open) {
setPreviewData(null); setPreviewData(null);
if (!placeLoading) { if (!placeLoading) {
previewRequestSeqRef.current += 1;
previewSubmissionRef.current = null;
clearPlaceTraceId(); clearPlaceTraceId();
} }
} }