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

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

View File

@@ -16,3 +16,6 @@ DB_CONN_MAX_IDLE_TIME=5m
DB_CONN_MAX_LIFETIME=30m DB_CONN_MAX_LIFETIME=30m
MAINTENANCE_INTERVAL=1h MAINTENANCE_INTERVAL=1h
MAINTENANCE_BATCH_SIZE=500 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 `POST /api/public/projects/{id}/tickets` uses the same ticket
validation, queue locking and idempotency rules as the staff ticket flow, but validation, queue locking and idempotency rules as the staff ticket flow, but
returns only the public ticket projection and a private status token. Public 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 `POST /api/public/status/search` is a temporary non-production operational-test
endpoint. It accepts `{ "phone": "..." }` and returns all current active endpoint. It accepts `{ "phone": "..." }` and returns all current active

View File

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

View File

@@ -4,6 +4,7 @@ import (
"encoding/base64" "encoding/base64"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestLoadAcceptsExactly32ByteKeys(t *testing.T) { func TestLoadAcceptsExactly32ByteKeys(t *testing.T) {
@@ -74,3 +75,66 @@ func TestLoadRequiresTLSAndReleaseMigrationsInProduction(t *testing.T) {
t.Fatalf("expected production migration mode error, got %v", err) 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 count int
} }
// queryLimiter is deliberately small and in-memory for the temporary public // queryLimiter is a per-process fixed-window safety limit. Expired entries are
// phone lookup. The production replacement will be an OTP or external // removed opportunistically so unique keys do not accumulate indefinitely.
// identity provider, so this limiter is only a safety net for the test flow.
type queryLimiter struct { type queryLimiter struct {
mu sync.Mutex mu sync.Mutex
entries map[string]queryLimitEntry entries map[string]queryLimitEntry
now func() time.Time now func() time.Time
limit int limit int
window time.Duration window time.Duration
nextCleanup time.Time
} }
func newQueryLimiter(now func() time.Time, limit int, window time.Duration) *queryLimiter { func newQueryLimiter(now func() time.Time, limit int, window time.Duration) *queryLimiter {
if limit <= 0 || window <= 0 {
return nil
}
return &queryLimiter{ return &queryLimiter{
entries: make(map[string]queryLimitEntry), now: now, limit: limit, window: window, 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() defer l.mu.Unlock()
now := l.now() 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] entry, ok := l.entries[key]
if !ok || now.Sub(entry.windowStart) >= l.window { if !ok || now.Sub(entry.windowStart) >= l.window {
entry = queryLimitEntry{windowStart: now} 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) { func TestLoginLimiterBlocksAfterTenFailures(t *testing.T) {
now := time.Unix(100, 0) now := time.Unix(100, 0)
limiter := newLoginLimiter(func() time.Time { return now }) 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) writeError(w, err)
return return
} }
if s.publicTicketLimiter != nil {
if allowed, retry := s.publicTicketLimiter.allow("ip:" + publicQueryClientKey(r)); !allowed {
writePublicTicketRateLimit(w, retry)
return
}
}
var actor model.User var actor model.User
if err := s.db.WithContext(r.Context()).Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil { if err := s.db.WithContext(r.Context()).Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil {
writeError(w, err) writeError(w, err)
@@ -74,6 +68,21 @@ func (s *Server) publicCreateTicket(w http.ResponseWriter, r *http.Request) {
s.createTicketForActor(w, r, actor.ID, true) 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 { type publicPhoneQueryRequest struct {
Phone string `json:"phone"` Phone string `json:"phone"`
} }

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -154,6 +155,43 @@ func TestPublicCreateTicketIgnoresDuplicateFromEndedSession(t *testing.T) {
if afterCallRecorder.Code != http.StatusCreated { if afterCallRecorder.Code != http.StatusCreated {
t.Fatalf("after-call status = %d, want 201; body = %s", afterCallRecorder.Code, afterCallRecorder.Body.String()) 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) { 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) 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 { type Server struct {
db *gorm.DB db *gorm.DB
config config.Config config config.Config
cipher *security.Cipher cipher *security.Cipher
logger *slog.Logger logger *slog.Logger
hub eventPublisher hub eventPublisher
loginLimiter *loginLimiter loginLimiter *loginLimiter
publicQueryLimiter *queryLimiter publicQueryLimiter *queryLimiter
publicTicketLimiter *queryLimiter publicTicketProjectLimiter *queryLimiter
dummyPassword string publicTicketPhoneLimiter *queryLimiter
now func() time.Time dummyPassword string
now func() time.Time
} }
func New(db *gorm.DB, cfg config.Config, logger *slog.Logger) (*Server, error) { 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() } now := func() time.Time { return time.Now().UTC() }
return &Server{ return &Server{
db: db, db: db,
config: cfg, config: cfg,
cipher: fieldCipher, cipher: fieldCipher,
logger: logger, logger: logger,
hub: publisher, hub: publisher,
loginLimiter: newLoginLimiter(now), loginLimiter: newLoginLimiter(now),
publicQueryLimiter: newQueryLimiter(now, 120, time.Minute), publicQueryLimiter: newQueryLimiter(now, 120, time.Minute),
publicTicketLimiter: newQueryLimiter(now, 20, time.Minute), publicTicketProjectLimiter: newQueryLimiter(now, cfg.PublicTicketProjectLimit, cfg.PublicTicketRateWindow),
dummyPassword: dummy, publicTicketPhoneLimiter: newQueryLimiter(now, cfg.PublicTicketPhoneLimit, cfg.PublicTicketRateWindow),
now: now, dummyPassword: dummy,
now: now,
}, nil }, nil
} }

View File

@@ -271,6 +271,20 @@ func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, ac
auditAction = "PUBLIC_TICKET_CREATED" auditAction = "PUBLIC_TICKET_CREATED"
duplicateMessage = "该手机号已有活动号码,请确认后继续" 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 var responseBody []byte
responseCode := http.StatusCreated responseCode := http.StatusCreated
var revision int64 var revision int64
@@ -293,7 +307,6 @@ func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, ac
return nil return nil
} }
phoneDigest := s.cipher.Digest(phone)
var duplicates []model.QueueTicket var duplicates []model.QueueTicket
if err := tx.Select("id", "display_number", "status"). if err := tx.Select("id", "display_number", "status").
Where("project_id = ? AND queue_session_id = ? AND phone_hmac = ? AND 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.ticket.party_size).toBe(4);
expect(response.public_token).toBe("visitor-token"); 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", () => { describe("public display overview", () => {

View File

@@ -34,16 +34,25 @@ export class ApiError extends Error {
readonly status: number; readonly status: number;
readonly details: unknown; readonly details: unknown;
readonly code?: string; 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); super(message);
this.name = "ApiError"; this.name = "ApiError";
this.status = status; this.status = status;
this.details = details; this.details = details;
this.code = code; 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> { async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
headers.set("Accept", "application/json"); headers.set("Accept", "application/json");
@@ -83,6 +92,7 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
response.status, response.status,
details, details,
typeof code === "string" ? code : undefined, typeof code === "string" ? code : undefined,
retryAfterSeconds(response),
); );
} }
return body as T; return body as T;

View File

@@ -100,4 +100,23 @@ describe("VisitorTakeTicketForm", () => {
createTicket.mockRestore(); 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) { 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 { function ticketStatusPath(response: CreateTicketResponse): string | null {
@@ -49,8 +49,17 @@ export function VisitorTakeTicketForm() {
const [confirmDuplicatePhone, setConfirmDuplicatePhone] = useState(false); const [confirmDuplicatePhone, setConfirmDuplicatePhone] = useState(false);
const [createdTicket, setCreatedTicket] = useState<CreateTicketResponse | null>(null); const [createdTicket, setCreatedTicket] = useState<CreateTicketResponse | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [retryAfterSeconds, setRetryAfterSeconds] = useState(0);
const createIntentRef = useRef<WriteIntent | null>(null); 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(() => { useEffect(() => {
if (!projects.length) return; if (!projects.length) return;
if (!projects.some((project) => project.id === selectedProjectId)) { if (!projects.some((project) => project.id === selectedProjectId)) {
@@ -91,6 +100,7 @@ export function VisitorTakeTicketForm() {
async function submitTicket(event: FormEvent<HTMLFormElement>) { async function submitTicket(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (retryAfterSeconds > 0) return;
if (!selectedProjectId) { if (!selectedProjectId) {
setFormError("请选择要排队的项目"); setFormError("请选择要排队的项目");
return; return;
@@ -118,14 +128,21 @@ export function VisitorTakeTicketForm() {
setCreatedTicket(response); setCreatedTicket(response);
setTicketForm({ ...initialTicket, party_size: minPartySize }); setTicketForm({ ...initialTicket, party_size: minPartySize });
setConfirmDuplicatePhone(false); setConfirmDuplicatePhone(false);
setRetryAfterSeconds(0);
} catch (caught) { } catch (caught) {
if (caught instanceof ApiError && caught.code === "DUPLICATE_PHONE") { if (caught instanceof ApiError && caught.code === "DUPLICATE_PHONE") {
createIntentRef.current = null; createIntentRef.current = null;
setConfirmDuplicatePhone(true); setConfirmDuplicatePhone(true);
setFormError(null); setFormError(null);
setRetryAfterSeconds(0);
} else if (caught instanceof ApiError && caught.status === 429) {
setConfirmDuplicatePhone(false);
setFormError(null);
setRetryAfterSeconds(caught.retryAfterSeconds ?? 60);
} else { } else {
releaseIntentAfterDefinitiveError(createIntentRef, caught); releaseIntentAfterDefinitiveError(createIntentRef, caught);
setConfirmDuplicatePhone(false); setConfirmDuplicatePhone(false);
setRetryAfterSeconds(0);
setFormError(caught instanceof ApiError ? caught.message : "取号未提交,请检查当前数据后重试。"); setFormError(caught instanceof ApiError ? caught.message : "取号未提交,请检查当前数据后重试。");
} }
} finally { } finally {
@@ -247,9 +264,10 @@ export function VisitorTakeTicketForm() {
</fieldset> </fieldset>
</div> </div>
{confirmDuplicatePhone ? <FeedbackBanner tone="warning" title="该手机号已有活动号码"></FeedbackBanner> : null} {confirmDuplicatePhone ? <FeedbackBanner tone="warning" title="该手机号已有活动号码"></FeedbackBanner> : null}
{retryAfterSeconds > 0 ? <FeedbackBanner tone="warning" title={`请求过于频繁,请在 ${retryAfterSeconds} 秒后重试。`} /> : null}
{formError ? <FeedbackBanner tone="danger" title={formError} /> : null} {formError ? <FeedbackBanner tone="danger" title={formError} /> : null}
<button className="button button--primary button--wide" type="submit" disabled={busy || !selectedProjectId || !projects.length}> <button className="button button--primary button--wide" type="submit" disabled={busy || retryAfterSeconds > 0 || !selectedProjectId || !projects.length}>
{busy ? "正在创建排队号码" : confirmDuplicatePhone ? "确认继续取号" : "创建排队号码"} {busy ? "正在创建排队号码" : retryAfterSeconds > 0 ? `请等待 ${retryAfterSeconds}` : confirmDuplicatePhone ? "确认继续取号" : "创建排队号码"}
</button> </button>
</form> </form>
)} )}