问题:移动网络共享出口 IP 会造成游客取号被误限流。 实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|