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

问题:移动网络共享出口 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

@@ -24,10 +24,15 @@ SESSION_COOKIE_SECURE=true
MIGRATE_ON_START=false
MAINTENANCE_INTERVAL=1h
MAINTENANCE_BATCH_SIZE=500
PUBLIC_TICKET_PROJECT_LIMIT=1000
PUBLIC_TICKET_PHONE_LIMIT=5
PUBLIC_TICKET_RATE_WINDOW=1m
```
`DATABASE_URL` 必须使用 `require``verify-ca``verify-full` 之一;生产 API 会拒绝 `sslmode=disable``MIGRATE_ON_START=true`
游客取号不按客户端 IP 拒绝请求,而是按项目总量和“项目 + 手机号 HMAC”限流。当前实现为单 Pod 进程内计数;如果 API 扩为多 Pod 且要求全局一致阈值,需要接入共享限流存储。手机号明文不得写入限流键、日志或外部缓存。
## 结构化接口日志
- 关键业务 API 的 `http request` 日志包含 `request``response`、状态码、耗时和 `request_id`JSON 正文单向最多记录 64 KiB超过后只记录字节数与 `body_truncated=true`

View File

@@ -254,7 +254,7 @@ Idempotency-Key: <unique-key>
| 422 | `INVALID_LAST_NAME``INVALID_HONORIFIC` | 修正可选字段 |
| 429 | `PUBLIC_TICKET_RATE_LIMITED` | 读取 `Retry-After` 秒数,等待后再试 |
当前实现的公开取号限流为同一客户端 IP 每分钟最多 20 次,具体阈值以后端配置和网关策略为准,小程序不要将该阈值写死业务规则。
当前公开取号不按客户端 IP 拒绝请求。服务端分别按项目总请求量、项目与规范化手机号组合进行限流;默认窗口为 1 分钟,默认每个项目最多 1000 次、同一项目和手机号最多 5 次。具体阈值以后端配置为准,小程序不要写死业务规则;收到 `429` 时必须读取 `Retry-After` 并等待后再试
### 5.3 查询单个号码状态

View File

@@ -16,3 +16,6 @@ DB_CONN_MAX_IDLE_TIME=5m
DB_CONN_MAX_LIFETIME=30m
MAINTENANCE_INTERVAL=1h
MAINTENANCE_BATCH_SIZE=500
PUBLIC_TICKET_PROJECT_LIMIT=1000
PUBLIC_TICKET_PHONE_LIMIT=5
PUBLIC_TICKET_RATE_WINDOW=1m

View File

@@ -51,7 +51,11 @@ self-service, including each project's allowed party-size range.
`POST /api/public/projects/{id}/tickets` uses the same ticket
validation, queue locking and idempotency rules as the staff ticket flow, but
returns only the public ticket projection and a private status token. Public
ticket creation is rate-limited and audited as `PUBLIC_TICKET_CREATED`.
ticket creation is rate-limited per project and per normalized phone HMAC, and
audited as `PUBLIC_TICKET_CREATED`. The default limits are 1000 project requests
and 5 requests for the same project/phone in one minute; configure them with
`PUBLIC_TICKET_PROJECT_LIMIT`, `PUBLIC_TICKET_PHONE_LIMIT` and
`PUBLIC_TICKET_RATE_WINDOW`. Ticket creation does not reject by client IP.
`POST /api/public/status/search` is a temporary non-production operational-test
endpoint. It accepts `{ "phone": "..." }` and returns all current active

View File

@@ -12,22 +12,25 @@ import (
)
type Config struct {
Environment string
HTTPAddr string
DatabaseURL string
EncryptionKey []byte
PhoneHMACKey []byte
SessionCookieName string
SessionSecure bool
SessionTTL time.Duration
MigrateOnStart bool
ShutdownTimeout time.Duration
DBMaxOpenConns int
DBMaxIdleConns int
DBConnMaxIdleTime time.Duration
DBConnMaxLifetime time.Duration
MaintenanceInterval time.Duration
MaintenanceBatchSize int
Environment string
HTTPAddr string
DatabaseURL string
EncryptionKey []byte
PhoneHMACKey []byte
SessionCookieName string
SessionSecure bool
SessionTTL time.Duration
MigrateOnStart bool
ShutdownTimeout time.Duration
DBMaxOpenConns int
DBMaxIdleConns int
DBConnMaxIdleTime time.Duration
DBConnMaxLifetime time.Duration
MaintenanceInterval time.Duration
MaintenanceBatchSize int
PublicTicketProjectLimit int
PublicTicketPhoneLimit int
PublicTicketRateWindow time.Duration
}
func Load() (Config, error) {
@@ -100,6 +103,18 @@ func load(lookup func(string) (string, bool)) (Config, error) {
if err != nil {
return Config{}, err
}
publicTicketProjectLimit, err := parseIntSetting("PUBLIC_TICKET_PROJECT_LIMIT", get("PUBLIC_TICKET_PROJECT_LIMIT", "1000"), 1, 1000000)
if err != nil {
return Config{}, err
}
publicTicketPhoneLimit, err := parseIntSetting("PUBLIC_TICKET_PHONE_LIMIT", get("PUBLIC_TICKET_PHONE_LIMIT", "5"), 1, 10000)
if err != nil {
return Config{}, err
}
publicTicketRateWindow, err := parseDurationSetting("PUBLIC_TICKET_RATE_WINDOW", get("PUBLIC_TICKET_RATE_WINDOW", "1m"), time.Second, time.Hour)
if err != nil {
return Config{}, err
}
if environment == "production" && !sessionSecure {
return Config{}, errors.New("SESSION_COOKIE_SECURE must be true in production")
}
@@ -119,22 +134,25 @@ func load(lookup func(string) (string, bool)) (Config, error) {
}
return Config{
Environment: environment,
HTTPAddr: get("HTTP_ADDR", ":8080"),
DatabaseURL: databaseURL,
EncryptionKey: encryptionKey,
PhoneHMACKey: phoneHMACKey,
SessionCookieName: cookieName,
SessionSecure: sessionSecure,
SessionTTL: sessionTTL,
MigrateOnStart: migrate,
ShutdownTimeout: shutdownTimeout,
DBMaxOpenConns: maxOpen,
DBMaxIdleConns: maxIdle,
DBConnMaxIdleTime: connMaxIdleTime,
DBConnMaxLifetime: connMaxLifetime,
MaintenanceInterval: maintenanceInterval,
MaintenanceBatchSize: maintenanceBatchSize,
Environment: environment,
HTTPAddr: get("HTTP_ADDR", ":8080"),
DatabaseURL: databaseURL,
EncryptionKey: encryptionKey,
PhoneHMACKey: phoneHMACKey,
SessionCookieName: cookieName,
SessionSecure: sessionSecure,
SessionTTL: sessionTTL,
MigrateOnStart: migrate,
ShutdownTimeout: shutdownTimeout,
DBMaxOpenConns: maxOpen,
DBMaxIdleConns: maxIdle,
DBConnMaxIdleTime: connMaxIdleTime,
DBConnMaxLifetime: connMaxLifetime,
MaintenanceInterval: maintenanceInterval,
MaintenanceBatchSize: maintenanceBatchSize,
PublicTicketProjectLimit: publicTicketProjectLimit,
PublicTicketPhoneLimit: publicTicketPhoneLimit,
PublicTicketRateWindow: publicTicketRateWindow,
}, nil
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/base64"
"strings"
"testing"
"time"
)
func TestLoadAcceptsExactly32ByteKeys(t *testing.T) {
@@ -74,3 +75,66 @@ func TestLoadRequiresTLSAndReleaseMigrationsInProduction(t *testing.T) {
t.Fatalf("expected production migration mode error, got %v", err)
}
}
func TestLoadConfiguresPublicTicketRateLimits(t *testing.T) {
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", 32)))
values := map[string]string{
"DATABASE_URL": "postgres://localhost/test",
"DATA_ENCRYPTION_KEY_BASE64": key,
"PHONE_HMAC_KEY_BASE64": key,
"SESSION_COOKIE_SECURE": "false",
}
lookup := func(name string) (string, bool) { value, ok := values[name]; return value, ok }
cfg, err := load(lookup)
if err != nil {
t.Fatal(err)
}
if cfg.PublicTicketProjectLimit != 1000 || cfg.PublicTicketPhoneLimit != 5 || cfg.PublicTicketRateWindow != time.Minute {
t.Fatalf("unexpected public ticket limits: project=%d phone=%d window=%s",
cfg.PublicTicketProjectLimit, cfg.PublicTicketPhoneLimit, cfg.PublicTicketRateWindow)
}
values["PUBLIC_TICKET_PROJECT_LIMIT"] = "1200"
values["PUBLIC_TICKET_PHONE_LIMIT"] = "8"
values["PUBLIC_TICKET_RATE_WINDOW"] = "2m"
cfg, err = load(lookup)
if err != nil {
t.Fatal(err)
}
if cfg.PublicTicketProjectLimit != 1200 || cfg.PublicTicketPhoneLimit != 8 || cfg.PublicTicketRateWindow != 2*time.Minute {
t.Fatalf("configured public ticket limits not applied: project=%d phone=%d window=%s",
cfg.PublicTicketProjectLimit, cfg.PublicTicketPhoneLimit, cfg.PublicTicketRateWindow)
}
}
func TestLoadRejectsInvalidPublicTicketRateLimits(t *testing.T) {
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", 32)))
base := map[string]string{
"DATABASE_URL": "postgres://localhost/test",
"DATA_ENCRYPTION_KEY_BASE64": key,
"PHONE_HMAC_KEY_BASE64": key,
"SESSION_COOKIE_SECURE": "false",
}
tests := []struct {
name string
key string
value string
}{
{name: "project limit", key: "PUBLIC_TICKET_PROJECT_LIMIT", value: "0"},
{name: "phone limit", key: "PUBLIC_TICKET_PHONE_LIMIT", value: "0"},
{name: "window", key: "PUBLIC_TICKET_RATE_WINDOW", value: "0s"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
values := make(map[string]string, len(base)+1)
for name, value := range base {
values[name] = value
}
values[test.key] = test.value
if _, err := load(func(name string) (string, bool) { value, ok := values[name]; return value, ok }); err == nil {
t.Fatalf("%s=%q should be rejected", test.key, test.value)
}
})
}
}

View File

@@ -24,18 +24,21 @@ type queryLimitEntry struct {
count int
}
// queryLimiter is deliberately small and in-memory for the temporary public
// phone lookup. The production replacement will be an OTP or external
// identity provider, so this limiter is only a safety net for the test flow.
// queryLimiter is a per-process fixed-window safety limit. Expired entries are
// removed opportunistically so unique keys do not accumulate indefinitely.
type queryLimiter struct {
mu sync.Mutex
entries map[string]queryLimitEntry
now func() time.Time
limit int
window time.Duration
mu sync.Mutex
entries map[string]queryLimitEntry
now func() time.Time
limit int
window time.Duration
nextCleanup time.Time
}
func newQueryLimiter(now func() time.Time, limit int, window time.Duration) *queryLimiter {
if limit <= 0 || window <= 0 {
return nil
}
return &queryLimiter{
entries: make(map[string]queryLimitEntry), now: now, limit: limit, window: window,
}
@@ -49,6 +52,14 @@ func (l *queryLimiter) allow(key string) (bool, time.Duration) {
defer l.mu.Unlock()
now := l.now()
if l.nextCleanup.IsZero() || !now.Before(l.nextCleanup) {
for entryKey, existing := range l.entries {
if now.Sub(existing.windowStart) >= l.window {
delete(l.entries, entryKey)
}
}
l.nextCleanup = now.Add(l.window)
}
entry, ok := l.entries[key]
if !ok || now.Sub(entry.windowStart) >= l.window {
entry = queryLimitEntry{windowStart: now}

View File

@@ -25,6 +25,23 @@ func TestQueryLimiterResetsAfterWindow(t *testing.T) {
}
}
func TestQueryLimiterRemovesExpiredEntries(t *testing.T) {
now := time.Unix(100, 0)
limiter := newQueryLimiter(func() time.Time { return now }, 2, time.Minute)
limiter.allow("project:old-a")
limiter.allow("project:old-b")
now = now.Add(2 * time.Minute)
limiter.allow("project:current")
if len(limiter.entries) != 1 {
t.Fatalf("entries=%d, want only the current window entry", len(limiter.entries))
}
if _, ok := limiter.entries["project:current"]; !ok {
t.Fatal("current limiter entry was removed")
}
}
func TestLoginLimiterBlocksAfterTenFailures(t *testing.T) {
now := time.Unix(100, 0)
limiter := newLoginLimiter(func() time.Time { return now })

View File

@@ -60,12 +60,6 @@ func (s *Server) publicCreateTicket(w http.ResponseWriter, r *http.Request) {
writeError(w, err)
return
}
if s.publicTicketLimiter != nil {
if allowed, retry := s.publicTicketLimiter.allow("ip:" + publicQueryClientKey(r)); !allowed {
writePublicTicketRateLimit(w, retry)
return
}
}
var actor model.User
if err := s.db.WithContext(r.Context()).Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil {
writeError(w, err)
@@ -74,6 +68,21 @@ func (s *Server) publicCreateTicket(w http.ResponseWriter, r *http.Request) {
s.createTicketForActor(w, r, actor.ID, true)
}
func publicTicketProjectLimitKey(projectID string) string {
return "project:" + projectID
}
func publicTicketPhoneLimitKey(projectID, phoneDigest string) string {
return "project:" + projectID + ":phone:" + phoneDigest
}
func (s *Server) allowPublicTicket(projectID, phoneDigest string) (bool, time.Duration) {
if allowed, retry := s.publicTicketProjectLimiter.allow(publicTicketProjectLimitKey(projectID)); !allowed {
return false, retry
}
return s.publicTicketPhoneLimiter.allow(publicTicketPhoneLimitKey(projectID, phoneDigest))
}
type publicPhoneQueryRequest struct {
Phone string `json:"phone"`
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
@@ -154,6 +155,43 @@ func TestPublicCreateTicketIgnoresDuplicateFromEndedSession(t *testing.T) {
if afterCallRecorder.Code != http.StatusCreated {
t.Fatalf("after-call status = %d, want 201; body = %s", afterCallRecorder.Code, afterCallRecorder.Body.String())
}
server.publicTicketProjectLimiter = newQueryLimiter(server.now, 10, time.Minute)
server.publicTicketPhoneLimiter = newQueryLimiter(server.now, 1, time.Minute)
var replayBody, replayKey string
for index := 0; index < 6; index++ {
body := fmt.Sprintf(`{"phone":"1390000%04d","party_size":1}`, index)
key := "same-mobile-ip-" + uuid.NewString()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(body))
request.RemoteAddr = "198.51.100.27:42000"
request.Header.Set("Idempotency-Key", key)
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("same-IP distinct phone %d status = %d, want 201; body = %s", index, recorder.Code, recorder.Body.String())
}
if index == 0 {
replayBody, replayKey = body, key
}
}
replayRecorder := httptest.NewRecorder()
replayRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(replayBody))
replayRequest.RemoteAddr = "198.51.100.27:42000"
replayRequest.Header.Set("Idempotency-Key", replayKey)
server.Handler().ServeHTTP(replayRecorder, replayRequest)
if replayRecorder.Code != http.StatusCreated {
t.Fatalf("idempotent replay status = %d, want 201; body = %s", replayRecorder.Code, replayRecorder.Body.String())
}
limitedRecorder := httptest.NewRecorder()
limitedRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(replayBody))
limitedRequest.RemoteAddr = "203.0.113.44:42000"
limitedRequest.Header.Set("Idempotency-Key", "new-intent-"+uuid.NewString())
server.Handler().ServeHTTP(limitedRecorder, limitedRequest)
if limitedRecorder.Code != http.StatusTooManyRequests {
t.Fatalf("same project/phone new intent status = %d, want 429; body = %s", limitedRecorder.Code, limitedRecorder.Body.String())
}
}
func TestInternalPhoneLookupUsesLatestActiveSessionPerProject(t *testing.T) {

View File

@@ -101,3 +101,40 @@ func TestPublicPhoneLookupRejectsInvalidPhone(t *testing.T) {
t.Fatalf("invalid phone lookup status = %d, want 422", recorder.Code)
}
}
func TestPublicTicketLimitsUseProjectAndPhoneDigest(t *testing.T) {
now := time.Unix(100, 0)
server := &Server{
publicTicketProjectLimiter: newQueryLimiter(func() time.Time { return now }, 10, time.Minute),
publicTicketPhoneLimiter: newQueryLimiter(func() time.Time { return now }, 1, time.Minute),
}
projectA := "11111111-1111-4111-8111-111111111111"
projectB := "22222222-2222-4222-8222-222222222222"
phoneDigest := strings.Repeat("a", 64)
if allowed, _ := server.allowPublicTicket(projectA, phoneDigest); !allowed {
t.Fatal("first project/phone request should be allowed")
}
if allowed, retry := server.allowPublicTicket(projectA, phoneDigest); allowed || retry <= 0 {
t.Fatalf("second request should be phone-limited, got allowed=%v retry=%s", allowed, retry)
}
if allowed, _ := server.allowPublicTicket(projectB, phoneDigest); !allowed {
t.Fatal("the same phone digest should have an independent limit in another project")
}
}
func TestPublicTicketLimitsEnforceProjectTotalAcrossPhones(t *testing.T) {
now := time.Unix(100, 0)
server := &Server{
publicTicketProjectLimiter: newQueryLimiter(func() time.Time { return now }, 1, time.Minute),
publicTicketPhoneLimiter: newQueryLimiter(func() time.Time { return now }, 10, time.Minute),
}
projectID := "11111111-1111-4111-8111-111111111111"
if allowed, _ := server.allowPublicTicket(projectID, strings.Repeat("a", 64)); !allowed {
t.Fatal("first project request should be allowed")
}
if allowed, retry := server.allowPublicTicket(projectID, strings.Repeat("b", 64)); allowed || retry <= 0 {
t.Fatalf("second phone should be limited by project total, got allowed=%v retry=%s", allowed, retry)
}
}

View File

@@ -27,16 +27,17 @@ const (
)
type Server struct {
db *gorm.DB
config config.Config
cipher *security.Cipher
logger *slog.Logger
hub eventPublisher
loginLimiter *loginLimiter
publicQueryLimiter *queryLimiter
publicTicketLimiter *queryLimiter
dummyPassword string
now func() time.Time
db *gorm.DB
config config.Config
cipher *security.Cipher
logger *slog.Logger
hub eventPublisher
loginLimiter *loginLimiter
publicQueryLimiter *queryLimiter
publicTicketProjectLimiter *queryLimiter
publicTicketPhoneLimiter *queryLimiter
dummyPassword string
now func() time.Time
}
func New(db *gorm.DB, cfg config.Config, logger *slog.Logger) (*Server, error) {
@@ -57,16 +58,17 @@ func NewWithEventPublisher(db *gorm.DB, cfg config.Config, logger *slog.Logger,
}
now := func() time.Time { return time.Now().UTC() }
return &Server{
db: db,
config: cfg,
cipher: fieldCipher,
logger: logger,
hub: publisher,
loginLimiter: newLoginLimiter(now),
publicQueryLimiter: newQueryLimiter(now, 120, time.Minute),
publicTicketLimiter: newQueryLimiter(now, 20, time.Minute),
dummyPassword: dummy,
now: now,
db: db,
config: cfg,
cipher: fieldCipher,
logger: logger,
hub: publisher,
loginLimiter: newLoginLimiter(now),
publicQueryLimiter: newQueryLimiter(now, 120, time.Minute),
publicTicketProjectLimiter: newQueryLimiter(now, cfg.PublicTicketProjectLimit, cfg.PublicTicketRateWindow),
publicTicketPhoneLimiter: newQueryLimiter(now, cfg.PublicTicketPhoneLimit, cfg.PublicTicketRateWindow),
dummyPassword: dummy,
now: now,
}, nil
}

View File

@@ -271,6 +271,20 @@ func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, ac
auditAction = "PUBLIC_TICKET_CREATED"
duplicateMessage = "该手机号已有活动号码,请确认后继续"
}
phoneDigest := s.cipher.Digest(phone)
if publicView && (s.publicTicketProjectLimiter != nil || s.publicTicketPhoneLimiter != nil) {
if stored, code, found, err := loadIdempotent(s.db.WithContext(r.Context()), projectID, actorID, scope, key, hash, s.now()); err != nil {
writeError(w, err)
return
} else if found {
writeRawJSON(w, code, stored)
return
}
if allowed, retry := s.allowPublicTicket(projectID, phoneDigest); !allowed {
writePublicTicketRateLimit(w, retry)
return
}
}
var responseBody []byte
responseCode := http.StatusCreated
var revision int64
@@ -293,7 +307,6 @@ func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, ac
return nil
}
phoneDigest := s.cipher.Digest(phone)
var duplicates []model.QueueTicket
if err := tx.Select("id", "display_number", "status").
Where("project_id = ? AND queue_session_id = ? AND phone_hmac = ? AND status = ?",

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>
)}