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