From c2a528153471a6fcefaee5ac27e3fd55fc796803 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 31 Jul 2026 22:56:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=B8=B8=E5=AE=A2=E5=8F=96=E5=8F=B7=E9=99=90=E6=B5=81=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:移动网络共享出口 IP 会造成游客取号被误限流。 实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。 --- docs/backend-production-handoff.md | 5 ++ docs/visitor-mini-program-integration.md | 2 +- server/.env.example | 3 + server/README.md | 6 +- server/internal/config/config.go | 82 +++++++++++-------- server/internal/config/config_test.go | 64 +++++++++++++++ server/internal/httpapi/limiter.go | 27 ++++-- server/internal/httpapi/limiter_test.go | 17 ++++ server/internal/httpapi/public.go | 21 +++-- .../httpapi/public_integration_test.go | 38 +++++++++ server/internal/httpapi/public_test.go | 37 +++++++++ server/internal/httpapi/server.go | 42 +++++----- server/internal/httpapi/staff.go | 15 +++- web/src/api.test.ts | 16 ++++ web/src/api.ts | 12 ++- web/src/pages/VisitorTakeTicketPage.test.tsx | 19 +++++ web/src/pages/VisitorTakeTicketPage.tsx | 24 +++++- 17 files changed, 357 insertions(+), 73 deletions(-) diff --git a/docs/backend-production-handoff.md b/docs/backend-production-handoff.md index 390aaa1..ebc235e 100644 --- a/docs/backend-production-handoff.md +++ b/docs/backend-production-handoff.md @@ -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`。 diff --git a/docs/visitor-mini-program-integration.md b/docs/visitor-mini-program-integration.md index a538d9a..cdb267f 100644 --- a/docs/visitor-mini-program-integration.md +++ b/docs/visitor-mini-program-integration.md @@ -254,7 +254,7 @@ Idempotency-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 查询单个号码状态 diff --git a/server/.env.example b/server/.env.example index 53e5eea..766fae3 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/README.md b/server/README.md index 8fd1f0a..f9b605b 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 9efa8aa..ef9264f 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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 } diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index ced1047..acc591b 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/server/internal/httpapi/limiter.go b/server/internal/httpapi/limiter.go index fdbec0e..6c5111d 100644 --- a/server/internal/httpapi/limiter.go +++ b/server/internal/httpapi/limiter.go @@ -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} diff --git a/server/internal/httpapi/limiter_test.go b/server/internal/httpapi/limiter_test.go index 71bd1b6..6ef5c5b 100644 --- a/server/internal/httpapi/limiter_test.go +++ b/server/internal/httpapi/limiter_test.go @@ -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 }) diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index 0ce7d3d..2cf1836 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -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"` } diff --git a/server/internal/httpapi/public_integration_test.go b/server/internal/httpapi/public_integration_test.go index 418e1b7..62fb3cc 100644 --- a/server/internal/httpapi/public_integration_test.go +++ b/server/internal/httpapi/public_integration_test.go @@ -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) { diff --git a/server/internal/httpapi/public_test.go b/server/internal/httpapi/public_test.go index a1dc977..04bb391 100644 --- a/server/internal/httpapi/public_test.go +++ b/server/internal/httpapi/public_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index b72ad0d..60298fd 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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 } diff --git a/server/internal/httpapi/staff.go b/server/internal/httpapi/staff.go index e08c379..bb6851b 100644 --- a/server/internal/httpapi/staff.go +++ b/server/internal/httpapi/staff.go @@ -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 = ?", diff --git a/web/src/api.test.ts b/web/src/api.test.ts index 17947a9..042397a 100644 --- a/web/src/api.test.ts +++ b/web/src/api.test.ts @@ -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", () => { diff --git a/web/src/api.ts b/web/src/api.ts index 5196a22..fe554fb 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); headers.set("Accept", "application/json"); @@ -83,6 +92,7 @@ async function request(path: string, init: RequestInit = {}): Promise { response.status, details, typeof code === "string" ? code : undefined, + retryAfterSeconds(response), ); } return body as T; diff --git a/web/src/pages/VisitorTakeTicketPage.test.tsx b/web/src/pages/VisitorTakeTicketPage.test.tsx index 0dbbc33..6eae615 100644 --- a/web/src/pages/VisitorTakeTicketPage.test.tsx +++ b/web/src/pages/VisitorTakeTicketPage.test.tsx @@ -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(); + 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(); + } + }); }); diff --git a/web/src/pages/VisitorTakeTicketPage.tsx b/web/src/pages/VisitorTakeTicketPage.tsx index 7be86a8..11be132 100644 --- a/web/src/pages/VisitorTakeTicketPage.tsx +++ b/web/src/pages/VisitorTakeTicketPage.tsx @@ -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(null); const [busy, setBusy] = useState(false); + const [retryAfterSeconds, setRetryAfterSeconds] = useState(0); const createIntentRef = useRef(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) { 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() { {confirmDuplicatePhone ? 如需继续取号,请再次点击确认按钮。 : null} + {retryAfterSeconds > 0 ? : null} {formError ? : null} - )}