Files
XQKqueue/web/src/pages/VisitorTakeTicketPage.tsx
brother7 c2a5281534 修复:调整游客取号限流策略
问题:移动网络共享出口 IP 会造成游客取号被误限流。

实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
2026-07-31 22:56:33 +08:00

278 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import { ApiError, api, newIdempotencyKey } from "../api";
import { FeedbackBanner, LoadingState } from "../components/Feedback";
import { usePollingResource } from "../hooks/usePollingResource";
import type { CreateTicketPayload, CreateTicketResponse } from "../types";
type VisitorTicketFormState = Omit<CreateTicketPayload, "party_size"> & { party_size: number | "" };
const initialTicket: VisitorTicketFormState = { phone: "", last_name: "", honorific: "", party_size: 1 };
interface WriteIntent {
fingerprint: string;
idempotencyKey: string;
}
function isPhoneInputValid(value: string): boolean {
const digits = value.replace(/\D/g, "");
return /^[+\d\s\-()]+$/.test(value) && digits.length >= 7 && digits.length <= 15;
}
function keyForIntent(ref: { current: WriteIntent | null }, fingerprint: string): string {
if (!ref.current || ref.current.fingerprint !== fingerprint) {
ref.current = { fingerprint, idempotencyKey: newIdempotencyKey() };
}
return ref.current.idempotencyKey;
}
function releaseIntentAfterDefinitiveError(ref: { current: WriteIntent | null }, error: unknown) {
if (error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 429) ref.current = null;
}
function ticketStatusPath(response: CreateTicketResponse): string | null {
if (response.public_url) return response.public_url;
if (response.public_token) return `/visitor/${response.public_token}`;
return null;
}
export function VisitorTakeTicketForm() {
const projectsResource = usePollingResource((signal) => api.publicProjects(signal), {
intervalMs: 60_000,
resourceKey: "visitor-public-projects",
});
const projects = projectsResource.data?.projects ?? [];
const [selectedProjectId, setSelectedProjectId] = useState("");
const [ticketForm, setTicketForm] = useState<VisitorTicketFormState>(initialTicket);
const [phoneError, setPhoneError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [confirmDuplicatePhone, setConfirmDuplicatePhone] = useState(false);
const [createdTicket, setCreatedTicket] = useState<CreateTicketResponse | null>(null);
const [busy, setBusy] = useState(false);
const [retryAfterSeconds, setRetryAfterSeconds] = useState(0);
const createIntentRef = useRef<WriteIntent | null>(null);
useEffect(() => {
if (retryAfterSeconds <= 0) return;
const timer = window.setTimeout(() => {
setRetryAfterSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearTimeout(timer);
}, [retryAfterSeconds]);
useEffect(() => {
if (!projects.length) return;
if (!projects.some((project) => project.id === selectedProjectId)) {
setSelectedProjectId(projects[0].id);
}
}, [projects, selectedProjectId]);
const selectedProject = projects.find((project) => project.id === selectedProjectId);
const minPartySize = selectedProject?.min_party_size ?? 1;
const maxPartySize = selectedProject?.max_party_size ?? minPartySize;
useEffect(() => {
if (!selectedProject) return;
setTicketForm((current) => ({
...current,
party_size: typeof current.party_size === "number" && current.party_size >= minPartySize && current.party_size <= maxPartySize
? current.party_size
: minPartySize,
}));
}, [selectedProject?.id]);
function updateTicketForm(patch: Partial<VisitorTicketFormState>) {
setTicketForm((current) => ({ ...current, ...patch }));
setConfirmDuplicatePhone(false);
setPhoneError(null);
setFormError(null);
createIntentRef.current = null;
}
function resetForAnotherTicket() {
setCreatedTicket(null);
setTicketForm({ ...initialTicket, party_size: minPartySize });
setConfirmDuplicatePhone(false);
setPhoneError(null);
setFormError(null);
createIntentRef.current = null;
}
async function submitTicket(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (retryAfterSeconds > 0) return;
if (!selectedProjectId) {
setFormError("请选择要排队的项目");
return;
}
const partySize = ticketForm.party_size;
if (typeof partySize !== "number" || !Number.isInteger(partySize) || partySize < minPartySize || partySize > maxPartySize) {
setFormError(`本项目每个号码可绑定 ${minPartySize}${maxPartySize}`);
return;
}
const phone = ticketForm.phone.trim();
if (!isPhoneInputValid(phone)) {
setPhoneError("请输入有效的手机号7-15 位数字)");
setFormError(null);
return;
}
setPhoneError(null);
setFormError(null);
setBusy(true);
const payload: CreateTicketPayload = { ...ticketForm, phone, party_size: partySize, allow_duplicate: confirmDuplicatePhone };
const fingerprint = JSON.stringify([selectedProjectId, payload]);
const idempotencyKey = keyForIntent(createIntentRef, fingerprint);
try {
const response = await api.publicCreateTicket(selectedProjectId, payload, idempotencyKey);
createIntentRef.current = null;
setCreatedTicket(response);
setTicketForm({ ...initialTicket, party_size: minPartySize });
setConfirmDuplicatePhone(false);
setRetryAfterSeconds(0);
} catch (caught) {
if (caught instanceof ApiError && caught.code === "DUPLICATE_PHONE") {
createIntentRef.current = null;
setConfirmDuplicatePhone(true);
setFormError(null);
setRetryAfterSeconds(0);
} else if (caught instanceof ApiError && caught.status === 429) {
setConfirmDuplicatePhone(false);
setFormError(null);
setRetryAfterSeconds(caught.retryAfterSeconds ?? 60);
} else {
releaseIntentAfterDefinitiveError(createIntentRef, caught);
setConfirmDuplicatePhone(false);
setRetryAfterSeconds(0);
setFormError(caught instanceof ApiError ? caught.message : "取号未提交,请检查当前数据后重试。");
}
} finally {
setBusy(false);
}
}
const statusPath = createdTicket ? ticketStatusPath(createdTicket) : null;
return (
<section className="visitor-lookup-card visitor-take-ticket-card" aria-label="取号">
{projectsResource.loading && !projects.length ? <LoadingState label="正在读取可取号项目" /> : null}
{projectsResource.error && !projects.length ? (
<FeedbackBanner
tone="danger"
title="暂时无法读取项目"
action={<button className="button button--secondary button--small" type="button" onClick={projectsResource.refresh}></button>}
>
{projectsResource.error.message}
</FeedbackBanner>
) : null}
{!projectsResource.loading && !projectsResource.error && !projects.length ? (
<FeedbackBanner tone="warning" title="当前暂无可取号项目"></FeedbackBanner>
) : null}
{createdTicket ? (
<div className="visitor-ticket-created" role="status" aria-live="polite">
<span></span>
<p></p>
<strong>{createdTicket.ticket.ticket_number}</strong>
<small>{selectedProject?.name ?? "当前项目"} · {createdTicket.ticket.party_size} · {createdTicket.ticket.phone_last4 ?? "未返回"}</small>
{statusPath ? <Link className="button button--primary button--wide" to={statusPath}></Link> : null}
<button className="button button--ghost button--wide" type="button" onClick={resetForAnotherTicket}></button>
</div>
) : (
<form className="visitor-lookup-form visitor-take-ticket-form" onSubmit={submitTicket} noValidate>
<label className="field" htmlFor="visitor-ticket-project">
<span></span>
<select
id="visitor-ticket-project"
value={selectedProjectId}
onChange={(event) => {
setSelectedProjectId(event.target.value);
setFormError(null);
}}
disabled={busy || !projects.length}
>
<option value=""></option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</label>
{selectedProject?.visitor_notice ? <p className="visitor-take-ticket__notice">{selectedProject.visitor_notice}</p> : null}
<label className="field" htmlFor="visitor-ticket-party-size">
<span></span>
<input
id="visitor-ticket-party-size"
name="party_size"
aria-label="同行人数"
type="number"
inputMode="numeric"
min={minPartySize}
max={maxPartySize}
value={ticketForm.party_size}
onChange={(event) => updateTicketForm({
party_size: Number.isNaN(event.currentTarget.valueAsNumber) ? "" : event.currentTarget.valueAsNumber,
})}
disabled={busy}
required
/>
<small> {minPartySize}{maxPartySize} </small>
</label>
<label className="field" htmlFor="visitor-ticket-phone">
<span></span>
<input
id="visitor-ticket-phone"
name="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="请输入手机号"
value={ticketForm.phone}
onChange={(event) => updateTicketForm({ phone: event.target.value })}
disabled={busy}
aria-invalid={phoneError ? "true" : "false"}
aria-describedby={phoneError ? "visitor-ticket-phone-error" : undefined}
/>
{phoneError ? <small id="visitor-ticket-phone-error" className="field-error" role="alert">{phoneError}</small> : null}
</label>
<div className="field-row">
<label className="field" htmlFor="visitor-ticket-last-name">
<span></span>
<input
id="visitor-ticket-last-name"
value={ticketForm.last_name}
onChange={(event) => updateTicketForm({ last_name: event.target.value })}
disabled={busy}
/>
</label>
<fieldset className="field honorific-field" disabled={busy}>
<legend></legend>
<div className="honorific-options">
{[
{ value: "", label: "不填写" },
{ value: "先生", label: "先生" },
{ value: "女士", label: "女士" },
].map((option) => (
<label key={option.label}>
<input
type="radio"
name="visitor-ticket-honorific"
value={option.value}
checked={ticketForm.honorific === option.value}
onChange={() => updateTicketForm({ honorific: option.value })}
/>
<span>{option.label}</span>
</label>
))}
</div>
</fieldset>
</div>
{confirmDuplicatePhone ? <FeedbackBanner tone="warning" title="该手机号已有活动号码"></FeedbackBanner> : null}
{retryAfterSeconds > 0 ? <FeedbackBanner tone="warning" title={`请求过于频繁,请在 ${retryAfterSeconds} 秒后重试。`} /> : null}
{formError ? <FeedbackBanner tone="danger" title={formError} /> : null}
<button className="button button--primary button--wide" type="submit" disabled={busy || retryAfterSeconds > 0 || !selectedProjectId || !projects.length}>
{busy ? "正在创建排队号码" : retryAfterSeconds > 0 ? `请等待 ${retryAfterSeconds}` : confirmDuplicatePhone ? "确认继续取号" : "创建排队号码"}
</button>
</form>
)}
</section>
);
}