修复:调整游客取号限流策略

问题:移动网络共享出口 IP 会造成游客取号被误限流。

实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
This commit is contained in:
2026-07-31 22:56:33 +08:00
parent 3f4a9bf398
commit c2a5281534
17 changed files with 357 additions and 73 deletions

View File

@@ -106,6 +106,22 @@ describe("public visitor lookup", () => {
expect(response.ticket.party_size).toBe(4);
expect(response.public_token).toBe("visitor-token");
});
it("exposes Retry-After for rate-limited ticket requests", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { code: "PUBLIC_TICKET_RATE_LIMITED", message: "取号次数过多,请稍后再试" },
}), {
status: 429,
headers: { "Content-Type": "application/json", "Retry-After": "17" },
}));
vi.stubGlobal("fetch", fetchMock);
await expect(api.publicCreateTicket("project-1", { phone: "13800138000", party_size: 1 })).rejects.toMatchObject({
code: "PUBLIC_TICKET_RATE_LIMITED",
status: 429,
retryAfterSeconds: 17,
});
});
});
describe("public display overview", () => {

View File

@@ -34,16 +34,25 @@ export class ApiError extends Error {
readonly status: number;
readonly details: unknown;
readonly code?: string;
readonly retryAfterSeconds?: number;
constructor(message: string, status = 0, details?: unknown, code?: string) {
constructor(message: string, status = 0, details?: unknown, code?: string, retryAfterSeconds?: number) {
super(message);
this.name = "ApiError";
this.status = status;
this.details = details;
this.code = code;
this.retryAfterSeconds = retryAfterSeconds;
}
}
function retryAfterSeconds(response: Response): number | undefined {
const value = response.headers.get("Retry-After");
if (!value) return undefined;
const seconds = Number(value);
return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : undefined;
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
@@ -83,6 +92,7 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
response.status,
details,
typeof code === "string" ? code : undefined,
retryAfterSeconds(response),
);
}
return body as T;

View File

@@ -100,4 +100,23 @@ describe("VisitorTakeTicketForm", () => {
createTicket.mockRestore();
}
});
it("shows the Retry-After wait without clearing the visitor form", async () => {
const createTicket = vi.spyOn(api, "publicCreateTicket").mockRejectedValueOnce(
new ApiError("取号次数过多,请稍后再试", 429, undefined, "PUBLIC_TICKET_RATE_LIMITED", 17),
);
try {
render(<MemoryRouter><VisitorTakeTicketForm /></MemoryRouter>);
const phoneInput = screen.getByLabelText("手机号");
fireEvent.change(phoneInput, { target: { value: "13800138000" } });
fireEvent.click(screen.getByRole("button", { name: "创建排队号码" }));
await waitFor(() => expect(screen.getByText(/17 秒后重试/)).toBeVisible());
expect(phoneInput).toHaveValue("13800138000");
expect(screen.getByRole("button", { name: /请等待 17 秒/ })).toBeDisabled();
} finally {
createTicket.mockRestore();
}
});
});

View File

@@ -27,7 +27,7 @@ function keyForIntent(ref: { current: WriteIntent | null }, fingerprint: string)
}
function releaseIntentAfterDefinitiveError(ref: { current: WriteIntent | null }, error: unknown) {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) ref.current = null;
if (error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 429) ref.current = null;
}
function ticketStatusPath(response: CreateTicketResponse): string | null {
@@ -49,8 +49,17 @@ export function VisitorTakeTicketForm() {
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)) {
@@ -91,6 +100,7 @@ export function VisitorTakeTicketForm() {
async function submitTicket(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (retryAfterSeconds > 0) return;
if (!selectedProjectId) {
setFormError("请选择要排队的项目");
return;
@@ -118,14 +128,21 @@ export function VisitorTakeTicketForm() {
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 {
@@ -247,9 +264,10 @@ export function VisitorTakeTicketForm() {
</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 || !selectedProjectId || !projects.length}>
{busy ? "正在创建排队号码" : confirmDuplicatePhone ? "确认继续取号" : "创建排队号码"}
<button className="button button--primary button--wide" type="submit" disabled={busy || retryAfterSeconds > 0 || !selectedProjectId || !projects.length}>
{busy ? "正在创建排队号码" : retryAfterSeconds > 0 ? `请等待 ${retryAfterSeconds}` : confirmDuplicatePhone ? "确认继续取号" : "创建排队号码"}
</button>
</form>
)}