修复:调整游客取号限流策略
问题:移动网络共享出口 IP 会造成游客取号被误限流。 实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user