2 Commits

Author SHA1 Message Date
6d911271e6 功能:内置公屏叫号音频并仅播号码
需求:屏行天下等设备缺少中文语音服务,speechSynthesis 无法播报;公屏不再播项目名。

实现:内置中文数字和提示语 WAV 素材,统一通过普通 audio 元素顺序组合播放,每次叫号重复三遍,并保留首次点击解锁。

验证:前端 65 项测试通过,生产构建通过,真实浏览器完成 WAV 解码播放且无媒体错误。
2026-07-31 23:11:39 +08:00
c2a5281534 修复:调整游客取号限流策略
问题:移动网络共享出口 IP 会造成游客取号被误限流。

实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
2026-07-31 22:56:33 +08:00
42 changed files with 624 additions and 241 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -106,6 +106,22 @@ describe("public visitor lookup", () => {
expect(response.ticket.party_size).toBe(4);
expect(response.public_token).toBe("visitor-token");
});
it("exposes Retry-After for rate-limited ticket requests", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { code: "PUBLIC_TICKET_RATE_LIMITED", message: "取号次数过多,请稍后再试" },
}), {
status: 429,
headers: { "Content-Type": "application/json", "Retry-After": "17" },
}));
vi.stubGlobal("fetch", fetchMock);
await expect(api.publicCreateTicket("project-1", { phone: "13800138000", party_size: 1 })).rejects.toMatchObject({
code: "PUBLIC_TICKET_RATE_LIMITED",
status: 429,
retryAfterSeconds: 17,
});
});
});
describe("public display overview", () => {

View File

@@ -34,16 +34,25 @@ export class ApiError extends Error {
readonly status: number;
readonly details: unknown;
readonly code?: string;
readonly retryAfterSeconds?: number;
constructor(message: string, status = 0, details?: unknown, code?: string) {
constructor(message: string, status = 0, details?: unknown, code?: string, retryAfterSeconds?: number) {
super(message);
this.name = "ApiError";
this.status = status;
this.details = details;
this.code = code;
this.retryAfterSeconds = retryAfterSeconds;
}
}
function retryAfterSeconds(response: Response): number | undefined {
const value = response.headers.get("Retry-After");
if (!value) return undefined;
const seconds = Number(value);
return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : undefined;
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
@@ -83,6 +92,7 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
response.status,
details,
typeof code === "string" ? code : undefined,
retryAfterSeconds(response),
);
}
return body as T;

View File

@@ -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>
)}
</>
);
}

View File

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

View 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",
]);
});
});

View 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 };
}

View File

@@ -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 };
}

View File

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

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -100,4 +100,23 @@ describe("VisitorTakeTicketForm", () => {
createTicket.mockRestore();
}
});
it("shows the Retry-After wait without clearing the visitor form", async () => {
const createTicket = vi.spyOn(api, "publicCreateTicket").mockRejectedValueOnce(
new ApiError("取号次数过多,请稍后再试", 429, undefined, "PUBLIC_TICKET_RATE_LIMITED", 17),
);
try {
render(<MemoryRouter><VisitorTakeTicketForm /></MemoryRouter>);
const phoneInput = screen.getByLabelText("手机号");
fireEvent.change(phoneInput, { target: { value: "13800138000" } });
fireEvent.click(screen.getByRole("button", { name: "创建排队号码" }));
await waitFor(() => expect(screen.getByText(/17 秒后重试/)).toBeVisible());
expect(phoneInput).toHaveValue("13800138000");
expect(screen.getByRole("button", { name: /请等待 17 秒/ })).toBeDisabled();
} finally {
createTicket.mockRestore();
}
});
});

View File

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