fix(panel): op-login 轮询按请求截止时间停止并显示倒计时,过期可一键重新发起,失败指数退避,服务端拒绝即停并提示

This commit is contained in:
Lemon-miaow committed 2026-09-25 11:58:13 +08:00
1 parent 367ef2678a
commit a5307d44d4
7 files changed
+375 -46

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { OP_LOGIN_TTL_MS, formatCountdown, opLoginDeadline, opPollDelay } from "./opLoginPoll";
const NOW = Date.parse("2026-09-25T12:00:00Z");
describe("opLoginDeadline", () => {
it("counts down to the server's expires_at when the clocks agree", () => {
expect(opLoginDeadline("2026-09-25T12:09:30Z", NOW)).toBe(NOW + 570_000);
});
it("falls back to the TTL from now when the clocks disagree", () => {
// The browser runs 20 minutes fast: taken literally the request died before it was shown.
expect(opLoginDeadline("2026-09-25T11:50:00Z", NOW)).toBe(NOW + OP_LOGIN_TTL_MS);
// The browser runs an hour slow: no request lives that long.
expect(opLoginDeadline("2026-09-25T13:10:00Z", NOW)).toBe(NOW + OP_LOGIN_TTL_MS);
expect(opLoginDeadline("not a time", NOW)).toBe(NOW + OP_LOGIN_TTL_MS);
});
});
describe("opPollDelay", () => {
it("asks every 3s while calls succeed and doubles per failure up to 30s", () => {
expect([0, 1, 2, 3, 4, 10].map(opPollDelay)).toEqual([3000, 6000, 12000, 24000, 30000, 30000]);
});
});
describe("formatCountdown", () => {
it("writes minutes and zero-padded seconds, rounding a started second up", () => {
expect(formatCountdown(600_000)).toBe("10:00");
expect(formatCountdown(65_001)).toBe("1:06");
expect(formatCountdown(9_000)).toBe("0:09");
expect(formatCountdown(-5)).toBe("0:00");
});
});
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { ApiError } from "@/lib/types";
// The op-login status endpoint answers approved:false for an unknown, expired or
// used request alike (it must not tell a stranger which staff addresses exist), so
// it never says a request died. The expires_at that start returned is the only
// end the page gets.
/** OP_LOGIN_TTL_MS mirrors otpTTL in handlers_email_otp.go: how long a request lives. */
export const OP_LOGIN_TTL_MS = 10 * 60 * 1000;
export const OP_POLL_BASE_MS = 3000;
export const OP_POLL_MAX_MS = 30000;
/**
* opLoginDeadline turns start's expires_at into a deadline on this browser's
* clock. One already past, or further off than a request can live, means the two
* clocks disagree; the request was minted just now, so the TTL from now is the
* better estimate then.
*/
export function opLoginDeadline(expiresAt: string, now: number): number {
const remaining = Date.parse(expiresAt) - now;
if (!Number.isFinite(remaining) || remaining <= 0 || remaining > OP_LOGIN_TTL_MS) {
return now + OP_LOGIN_TTL_MS;
}
return now + remaining;
}
/** opPollDelay doubles the wait after each consecutive failure, capped at OP_POLL_MAX_MS. */
export function opPollDelay(failures: number): number {
return Math.min(OP_POLL_BASE_MS * 2 ** failures, OP_POLL_MAX_MS);
}
/** formatCountdown writes a remaining time as m:ss. */
export function formatCountdown(ms: number): string {
const total = Math.ceil(Math.max(0, ms) / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
// A failure worth asking again after: no answer at all, a rate limit, or the server
// side. Anything else (local sessions switched off, a malformed id) answers the
// same way every time.
function retryable(e: unknown): boolean {
const status = (e as Partial<ApiError> | null)?.status;
return status === undefined || status === 0 || status === 429 || status >= 500;
}
export interface OpLoginPoll {
approved: boolean;
expired: boolean;
remainingMs: number;
/** The last status call failed and the next one waits longer. */
retrying: boolean;
/** A refusal that asking again will not change; polling has stopped. */
error: unknown;
}
/**
* useOpLoginPoll asks whether the request was approved in-game until it is, until
* the deadline passes, or until the server refuses outright, backing off while the
* calls fail. The countdown keeps running after approval: the code has to be
* redeemed before the same deadline.
*/
export function useOpLoginPoll(requestId: string | null, deadline: number | null): OpLoginPoll {
const [approved, setApproved] = useState(false);
const [retrying, setRetrying] = useState(false);
const [error, setError] = useState<unknown>(null);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
setApproved(false);
setRetrying(false);
setError(null);
if (!requestId || deadline === null) return;
let cancelled = false;
let failures = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const poll = async () => {
if (Date.now() >= deadline) return;
try {
const s = await api.opLoginStatus(requestId);
if (cancelled) return;
failures = 0;
setRetrying(false);
if (s.approved) {
setApproved(true);
return;
}
} catch (e) {
if (cancelled) return;
if (!retryable(e)) {
setRetrying(false);
setError(e);
return;
}
failures += 1;
setRetrying(true);
}
timer = setTimeout(poll, opPollDelay(failures));
};
timer = setTimeout(poll, OP_POLL_BASE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [requestId, deadline]);
useEffect(() => {
if (deadline === null) return;
setNow(Date.now());
const tick = setInterval(() => {
const n = Date.now();
setNow(n);
if (n >= deadline) clearInterval(tick);
}, 1000);
return () => clearInterval(tick);
}, [deadline]);
const remainingMs = deadline === null ? 0 : Math.max(0, deadline - now);
return { approved, expired: deadline !== null && remainingMs === 0, remainingMs, retrying, error };
}