Compare commits
2 Commits
3f4a9bf398
...
6d911271e6
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d911271e6 | |||
| c2a5281534 |
@@ -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`。
|
||||
|
||||
@@ -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 查询单个号码状态
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = ?",
|
||||
|
||||
BIN
web/public/audio/queue-voice/digit-0.wav
Normal file
BIN
web/public/audio/queue-voice/digit-0.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-1.wav
Normal file
BIN
web/public/audio/queue-voice/digit-1.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-2.wav
Normal file
BIN
web/public/audio/queue-voice/digit-2.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-3.wav
Normal file
BIN
web/public/audio/queue-voice/digit-3.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-4.wav
Normal file
BIN
web/public/audio/queue-voice/digit-4.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-5.wav
Normal file
BIN
web/public/audio/queue-voice/digit-5.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-6.wav
Normal file
BIN
web/public/audio/queue-voice/digit-6.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-7.wav
Normal file
BIN
web/public/audio/queue-voice/digit-7.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-8.wav
Normal file
BIN
web/public/audio/queue-voice/digit-8.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-9.wav
Normal file
BIN
web/public/audio/queue-voice/digit-9.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/entrance.wav
Normal file
BIN
web/public/audio/queue-voice/entrance.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/gap.wav
Normal file
BIN
web/public/audio/queue-voice/gap.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/number.wav
Normal file
BIN
web/public/audio/queue-voice/number.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/please.wav
Normal file
BIN
web/public/audio/queue-voice/please.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/ready.wav
Normal file
BIN
web/public/audio/queue-voice/ready.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/through.wav
Normal file
BIN
web/public/audio/queue-voice/through.wav
Normal file
Binary file not shown.
@@ -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;
|
||||
|
||||
37
web/src/components/AudioControl.tsx
Normal file
37
web/src/components/AudioControl.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { AudioAnnouncementStatus } from "../hooks/useAudioAnnouncements";
|
||||
|
||||
interface AudioControlProps {
|
||||
audioRef: RefObject<HTMLAudioElement | null>;
|
||||
status: AudioAnnouncementStatus;
|
||||
onEnable: () => void;
|
||||
}
|
||||
|
||||
export function AudioControl({ audioRef, status, onEnable }: AudioControlProps) {
|
||||
return (
|
||||
<>
|
||||
<audio ref={audioRef} preload="auto" aria-hidden="true" />
|
||||
{status === "ready" ? (
|
||||
<aside className="speech-control speech-control--ready" role="status">
|
||||
<strong>叫号声音已开启</strong>
|
||||
<span>后续叫号将自动播报三次</span>
|
||||
</aside>
|
||||
) : (
|
||||
<aside className={`speech-control${status === "error" ? " speech-control--error" : ""}`} role="status">
|
||||
<div>
|
||||
<strong>{status === "error" ? "叫号声音开启失败" : "叫号声音尚未开启"}</strong>
|
||||
<span>{status === "error" ? "请检查设备媒体音量后重试" : "请点击一次,之后将自动播报"}</span>
|
||||
</div>
|
||||
<button
|
||||
className="button button--primary"
|
||||
type="button"
|
||||
disabled={status === "enabling"}
|
||||
onClick={onEnable}
|
||||
>
|
||||
{status === "enabling" ? "正在开启叫号声音" : status === "error" ? "重新开启叫号声音" : "开启叫号声音"}
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { SpeechAnnouncementStatus } from "../hooks/useSpeechAnnouncements";
|
||||
|
||||
interface SpeechControlProps {
|
||||
status: SpeechAnnouncementStatus;
|
||||
onEnable: () => void;
|
||||
}
|
||||
|
||||
export function SpeechControl({ status, onEnable }: SpeechControlProps) {
|
||||
if (status === "ready") {
|
||||
return (
|
||||
<aside className="speech-control speech-control--ready" role="status">
|
||||
<strong>叫号声音已开启</strong>
|
||||
<span>后续叫号将自动播报三次</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const unsupported = status === "unsupported";
|
||||
const failed = status === "error";
|
||||
return (
|
||||
<aside className={`speech-control${failed || unsupported ? " speech-control--error" : ""}`} role="status">
|
||||
<div>
|
||||
<strong>{unsupported ? "当前浏览器不支持叫号声音" : failed ? "叫号声音开启失败" : "叫号声音尚未开启"}</strong>
|
||||
<span>{unsupported || failed ? "请确认系统已安装中文语音服务" : "Chrome 需要先点击一次,之后才允许自动播报"}</span>
|
||||
</div>
|
||||
{!unsupported ? (
|
||||
<button
|
||||
className="button button--primary"
|
||||
type="button"
|
||||
disabled={status === "enabling"}
|
||||
onClick={onEnable}
|
||||
>
|
||||
{status === "enabling" ? "正在开启叫号声音" : failed ? "重新开启叫号声音" : "开启叫号声音"}
|
||||
</button>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
37
web/src/hooks/useAudioAnnouncements.test.ts
Normal file
37
web/src/hooks/useAudioAnnouncements.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { announcementAudioQueue } from "./useAudioAnnouncements";
|
||||
|
||||
describe("announcementAudioQueue", () => {
|
||||
it("忽略项目名并把单号码播报素材重复三遍", () => {
|
||||
const once = [
|
||||
"/audio/queue-voice/please.wav",
|
||||
"/audio/queue-voice/digit-0.wav",
|
||||
"/audio/queue-voice/digit-0.wav",
|
||||
"/audio/queue-voice/digit-0.wav",
|
||||
"/audio/queue-voice/digit-1.wav",
|
||||
"/audio/queue-voice/digit-6.wav",
|
||||
"/audio/queue-voice/number.wav",
|
||||
"/audio/queue-voice/entrance.wav",
|
||||
"/audio/queue-voice/gap.wav",
|
||||
];
|
||||
|
||||
expect(announcementAudioQueue(["东门观光车,请零零零一六号,前往入口。"]))
|
||||
.toEqual([...once, ...once, ...once]);
|
||||
});
|
||||
|
||||
it("支持连续号码范围", () => {
|
||||
const queue = announcementAudioQueue(["请一七号至一九号,前往入口。"]);
|
||||
|
||||
expect(queue.slice(0, 9)).toEqual([
|
||||
"/audio/queue-voice/please.wav",
|
||||
"/audio/queue-voice/digit-1.wav",
|
||||
"/audio/queue-voice/digit-7.wav",
|
||||
"/audio/queue-voice/number.wav",
|
||||
"/audio/queue-voice/through.wav",
|
||||
"/audio/queue-voice/digit-1.wav",
|
||||
"/audio/queue-voice/digit-9.wav",
|
||||
"/audio/queue-voice/number.wav",
|
||||
"/audio/queue-voice/entrance.wav",
|
||||
]);
|
||||
});
|
||||
});
|
||||
143
web/src/hooks/useAudioAnnouncements.ts
Normal file
143
web/src/hooks/useAudioAnnouncements.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type AudioAnnouncementStatus = "locked" | "enabling" | "ready" | "error";
|
||||
|
||||
const AUDIO_BASE_PATH = "/audio/queue-voice";
|
||||
const ANNOUNCEMENT_REPEAT_COUNT = 3;
|
||||
const GAP_AUDIO = `${AUDIO_BASE_PATH}/gap.wav`;
|
||||
|
||||
const DIGIT_AUDIO: Record<string, string> = {
|
||||
零: `${AUDIO_BASE_PATH}/digit-0.wav`,
|
||||
一: `${AUDIO_BASE_PATH}/digit-1.wav`,
|
||||
二: `${AUDIO_BASE_PATH}/digit-2.wav`,
|
||||
三: `${AUDIO_BASE_PATH}/digit-3.wav`,
|
||||
四: `${AUDIO_BASE_PATH}/digit-4.wav`,
|
||||
五: `${AUDIO_BASE_PATH}/digit-5.wav`,
|
||||
六: `${AUDIO_BASE_PATH}/digit-6.wav`,
|
||||
七: `${AUDIO_BASE_PATH}/digit-7.wav`,
|
||||
八: `${AUDIO_BASE_PATH}/digit-8.wav`,
|
||||
九: `${AUDIO_BASE_PATH}/digit-9.wav`,
|
||||
};
|
||||
|
||||
const PHRASE_AUDIO = [
|
||||
["前往入口", `${AUDIO_BASE_PATH}/entrance.wav`],
|
||||
["请", `${AUDIO_BASE_PATH}/please.wav`],
|
||||
["号", `${AUDIO_BASE_PATH}/number.wav`],
|
||||
["至", `${AUDIO_BASE_PATH}/through.wav`],
|
||||
] as const;
|
||||
|
||||
function clipsForAnnouncement(announcement: string): string[] {
|
||||
const callStart = announcement.indexOf("请");
|
||||
if (callStart < 0) return [];
|
||||
|
||||
const callText = announcement.slice(callStart);
|
||||
const clips: string[] = [];
|
||||
for (let index = 0; index < callText.length;) {
|
||||
const phrase = PHRASE_AUDIO.find(([text]) => callText.startsWith(text, index));
|
||||
if (phrase) {
|
||||
clips.push(phrase[1]);
|
||||
index += phrase[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const digit = DIGIT_AUDIO[callText[index]];
|
||||
if (digit) clips.push(digit);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return clips.length ? [...clips, GAP_AUDIO] : [];
|
||||
}
|
||||
|
||||
export function announcementAudioQueue(announcements: string[]): string[] {
|
||||
const queue: string[] = [];
|
||||
for (const announcement of announcements) {
|
||||
const clips = clipsForAnnouncement(announcement);
|
||||
for (let repeat = 0; repeat < ANNOUNCEMENT_REPEAT_COUNT; repeat += 1) {
|
||||
queue.push(...clips);
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
export function useAudioAnnouncements() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const playbackIdRef = useRef(0);
|
||||
const [status, setStatus] = useState<AudioAnnouncementStatus>("locked");
|
||||
|
||||
const playQueue = useCallback((sources: string[], onStarted?: () => void) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !sources.length) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const playbackId = ++playbackIdRef.current;
|
||||
audio.pause();
|
||||
audio.onended = null;
|
||||
audio.onerror = null;
|
||||
|
||||
let cursor = 0;
|
||||
let started = false;
|
||||
const markStarted = () => {
|
||||
if (started || playbackIdRef.current !== playbackId) return;
|
||||
started = true;
|
||||
onStarted?.();
|
||||
};
|
||||
const fail = (error?: unknown) => {
|
||||
if (playbackIdRef.current !== playbackId) return;
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
audio.onended = null;
|
||||
audio.onerror = null;
|
||||
setStatus("error");
|
||||
};
|
||||
const playNext = () => {
|
||||
if (playbackIdRef.current !== playbackId) return;
|
||||
if (cursor >= sources.length) {
|
||||
audio.onended = null;
|
||||
audio.onerror = null;
|
||||
return;
|
||||
}
|
||||
|
||||
audio.src = sources[cursor];
|
||||
cursor += 1;
|
||||
audio.currentTime = 0;
|
||||
audio.onended = playNext;
|
||||
audio.onerror = () => fail();
|
||||
|
||||
try {
|
||||
const result = audio.play();
|
||||
if (result && typeof result.then === "function") {
|
||||
void result.then(markStarted).catch(fail);
|
||||
} else {
|
||||
markStarted();
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
};
|
||||
|
||||
playNext();
|
||||
}, []);
|
||||
|
||||
const enable = useCallback(() => {
|
||||
setStatus("enabling");
|
||||
playQueue([`${AUDIO_BASE_PATH}/ready.wav`], () => setStatus("ready"));
|
||||
}, [playQueue]);
|
||||
|
||||
const announce = useCallback((announcements: string[]) => {
|
||||
if (status !== "ready" || !announcements.length) return;
|
||||
const queue = announcementAudioQueue(announcements);
|
||||
if (queue.length) playQueue(queue);
|
||||
}, [playQueue, status]);
|
||||
|
||||
useEffect(() => () => {
|
||||
playbackIdRef.current += 1;
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.pause();
|
||||
audio.onended = null;
|
||||
audio.onerror = null;
|
||||
}, []);
|
||||
|
||||
return { announce, audioRef, enable, status };
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export type SpeechAnnouncementStatus = "locked" | "enabling" | "ready" | "error" | "unsupported";
|
||||
|
||||
const ANNOUNCEMENT_REPEAT_COUNT = 3;
|
||||
|
||||
function speechSupported(): boolean {
|
||||
return typeof window !== "undefined"
|
||||
&& "speechSynthesis" in window
|
||||
&& typeof SpeechSynthesisUtterance !== "undefined";
|
||||
}
|
||||
|
||||
function createUtterance(text: string): SpeechSynthesisUtterance {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = "zh-CN";
|
||||
utterance.rate = 0.9;
|
||||
return utterance;
|
||||
}
|
||||
|
||||
export function useSpeechAnnouncements() {
|
||||
const [status, setStatus] = useState<SpeechAnnouncementStatus>(() => (
|
||||
speechSupported() ? "locked" : "unsupported"
|
||||
));
|
||||
|
||||
const enable = useCallback(() => {
|
||||
if (!speechSupported()) {
|
||||
setStatus("unsupported");
|
||||
return;
|
||||
}
|
||||
|
||||
const synthesis = window.speechSynthesis;
|
||||
const utterance = createUtterance("叫号声音已开启。");
|
||||
utterance.onstart = () => setStatus("ready");
|
||||
utterance.onend = () => setStatus("ready");
|
||||
utterance.onerror = (event) => {
|
||||
if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error");
|
||||
};
|
||||
|
||||
setStatus("enabling");
|
||||
synthesis.cancel();
|
||||
if (synthesis.paused) synthesis.resume();
|
||||
synthesis.speak(utterance);
|
||||
}, []);
|
||||
|
||||
const announce = useCallback((announcements: string[]) => {
|
||||
if (status !== "ready" || !announcements.length || !speechSupported()) return;
|
||||
|
||||
const synthesis = window.speechSynthesis;
|
||||
synthesis.cancel();
|
||||
if (synthesis.paused) synthesis.resume();
|
||||
for (const announcement of announcements) {
|
||||
for (let repeat = 0; repeat < ANNOUNCEMENT_REPEAT_COUNT; repeat += 1) {
|
||||
const utterance = createUtterance(announcement);
|
||||
utterance.onerror = (event) => {
|
||||
if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error");
|
||||
};
|
||||
synthesis.speak(utterance);
|
||||
}
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return { announce, enable, status };
|
||||
}
|
||||
@@ -48,6 +48,7 @@ vi.mock("../hooks/usePollingResource", () => ({
|
||||
}));
|
||||
|
||||
import { DisplayPage, forecastRows } from "./DisplayPage";
|
||||
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
|
||||
|
||||
describe("DisplayPage", () => {
|
||||
beforeEach(() => {
|
||||
@@ -94,33 +95,22 @@ describe("DisplayPage", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("新批次出现时连续播报三次叫号内容", () => {
|
||||
const speak = vi.fn();
|
||||
const cancel = vi.fn();
|
||||
class SpeechSynthesisUtteranceMock {
|
||||
text: string;
|
||||
lang = "";
|
||||
rate = 1;
|
||||
onstart: (() => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
onerror: ((event: { error: string }) => void) | null = null;
|
||||
it("新批次出现时用内置音频连续播报三次叫号内容", async () => {
|
||||
const playedSources: string[] = [];
|
||||
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
|
||||
playedSources.push(this.getAttribute("src") ?? "");
|
||||
return Promise.resolve();
|
||||
});
|
||||
const { container, rerender } = render(<DisplayPage />);
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
|
||||
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
|
||||
const { rerender } = render(<DisplayPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
||||
act(() => {
|
||||
speak.mock.calls[0][0].onstart?.();
|
||||
speak.mock.calls[0][0].onerror?.({ error: "interrupted" });
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByText("叫号声音已开启")).toBeVisible();
|
||||
speak.mockClear();
|
||||
cancel.mockClear();
|
||||
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
|
||||
playedSources.length = 0;
|
||||
|
||||
pollingResource.data.current_batch = {
|
||||
batch_number: 3,
|
||||
@@ -134,14 +124,12 @@ describe("DisplayPage", () => {
|
||||
rerender(<DisplayPage />);
|
||||
rerender(<DisplayPage />);
|
||||
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(speak).toHaveBeenCalledTimes(3);
|
||||
for (const [utterance] of speak.mock.calls) {
|
||||
expect(utterance).toMatchObject({
|
||||
text: "请零零零零五号至零零零零六号,前往入口。",
|
||||
lang: "zh-CN",
|
||||
rate: 0.9,
|
||||
});
|
||||
const expectedQueue = announcementAudioQueue(["请零零零零五号至零零零零六号,前往入口。"]).slice();
|
||||
const audio = container.querySelector("audio");
|
||||
expect(audio).not.toBeNull();
|
||||
for (let index = 1; index < expectedQueue.length; index += 1) {
|
||||
fireEvent.ended(audio!);
|
||||
}
|
||||
expect(playedSources).toEqual(expectedQueue);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { AudioControl } from "../components/AudioControl";
|
||||
import { ProjectScreenTile } from "../components/ProjectScreenTile";
|
||||
import { SpeechControl } from "../components/SpeechControl";
|
||||
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
|
||||
import { usePollingResource } from "../hooks/usePollingResource";
|
||||
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
|
||||
import { formatCallAnnouncement, formatDateTime, isTimestampStale } from "../lib/format";
|
||||
import type { AdminProjectDto } from "../types";
|
||||
|
||||
@@ -12,7 +12,7 @@ export { forecastRows } from "../lib/display";
|
||||
|
||||
export function DisplayPage() {
|
||||
const { token = "" } = useParams();
|
||||
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
|
||||
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements();
|
||||
const resource = usePollingResource((signal) => api.display(token, signal), {
|
||||
enabled: Boolean(token),
|
||||
intervalMs: 3_000,
|
||||
@@ -70,7 +70,7 @@ export function DisplayPage() {
|
||||
|
||||
return (
|
||||
<main className="single-display-screen">
|
||||
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
|
||||
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} />
|
||||
{(resource.offline || stale || resource.error) ? (
|
||||
<div className="display-alert" role="status">
|
||||
<strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong>
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("../hooks/usePollingResource", () => ({
|
||||
}));
|
||||
|
||||
import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage";
|
||||
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
|
||||
|
||||
describe("PublicDisplayCenterPage", () => {
|
||||
beforeEach(() => {
|
||||
@@ -62,28 +63,22 @@ describe("PublicDisplayCenterPage", () => {
|
||||
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("新叫号批次出现时连续三次播报项目和号码", () => {
|
||||
const speak = vi.fn();
|
||||
class SpeechSynthesisUtteranceMock {
|
||||
text: string;
|
||||
lang = "";
|
||||
rate = 1;
|
||||
onstart: (() => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
onerror: ((event: { error: string }) => void) | null = null;
|
||||
it("新叫号批次出现时只用内置音频连续三次播报号码", async () => {
|
||||
const playedSources: string[] = [];
|
||||
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
|
||||
playedSources.push(this.getAttribute("src") ?? "");
|
||||
return Promise.resolve();
|
||||
});
|
||||
const { container, rerender } = render(<PublicDisplayCenterPage />);
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
|
||||
vi.stubGlobal("speechSynthesis", { speak, cancel: vi.fn(), paused: false, resume: vi.fn() });
|
||||
const { rerender } = render(<PublicDisplayCenterPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
||||
act(() => speak.mock.calls[0][0].onstart?.());
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByText("叫号声音已开启")).toBeVisible();
|
||||
speak.mockClear();
|
||||
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
|
||||
playedSources.length = 0;
|
||||
|
||||
pollingResource.data = {
|
||||
...pollingResource.data,
|
||||
@@ -104,12 +99,12 @@ describe("PublicDisplayCenterPage", () => {
|
||||
};
|
||||
rerender(<PublicDisplayCenterPage />);
|
||||
|
||||
expect(speak).toHaveBeenCalledTimes(3);
|
||||
for (const [utterance] of speak.mock.calls) {
|
||||
expect(utterance).toMatchObject({
|
||||
text: "东门观光车,请零零零一六号,前往入口。",
|
||||
lang: "zh-CN",
|
||||
});
|
||||
const expectedQueue = announcementAudioQueue(["请零零零一六号,前往入口。"]).slice();
|
||||
const audio = container.querySelector("audio");
|
||||
expect(audio).not.toBeNull();
|
||||
for (let index = 1; index < expectedQueue.length; index += 1) {
|
||||
fireEvent.ended(audio!);
|
||||
}
|
||||
expect(playedSources).toEqual(expectedQueue);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { api } from "../api";
|
||||
import { AudioControl } from "../components/AudioControl";
|
||||
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
|
||||
import { SpeechControl } from "../components/SpeechControl";
|
||||
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
|
||||
import { usePollingResource } from "../hooks/usePollingResource";
|
||||
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
|
||||
import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
|
||||
import type { CallBatchDto } from "../types";
|
||||
import { DisplayCenter } from "./AdminPage";
|
||||
@@ -13,7 +13,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
|
||||
}
|
||||
|
||||
export function PublicDisplayCenterPage() {
|
||||
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
|
||||
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements();
|
||||
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
|
||||
const data = resource.data;
|
||||
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
|
||||
@@ -29,7 +29,7 @@ export function PublicDisplayCenterPage() {
|
||||
nextCalls.set(project.id, key);
|
||||
if (!previousCalls?.has(project.id) || !key || previousCalls.get(project.id) === key) continue;
|
||||
const announcement = formatCallAnnouncement(batch);
|
||||
if (announcement) announcements.push(`${project.name},${announcement}`);
|
||||
if (announcement) announcements.push(announcement);
|
||||
}
|
||||
lastCallsRef.current = nextCalls;
|
||||
announce(announcements);
|
||||
@@ -38,7 +38,7 @@ export function PublicDisplayCenterPage() {
|
||||
return (
|
||||
<div className="app-shell app-shell--admin app-shell--no-primary-action">
|
||||
<a className="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
|
||||
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} />
|
||||
<header className="app-header">
|
||||
<div className="brand-lockup" aria-label="景区排队叫号系统">
|
||||
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>
|
||||
|
||||
@@ -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