5 Commits

Author SHA1 Message Date
4405bf96f7 修复:将叫号音频内联到前端包
问题:音频使用 /audio 路径,浏览器预加载仍会向服务端发起独立请求。

实现:将 15 个 WAV 作为 Vite 内联资源编入前端 JS,后端继续仅返回叫号数据/文本。

验证:前端 66 项测试与生产构建通过;dist 中 WAV 文件为 0,含 15 个内联音频数据。
2026-08-01 12:16:54 +08:00
6d07612bc9 优化:公屏预加载叫号音频素材
需求:页面打开后缓存音频片段,叫号时避免临时下载造成延迟。

实现:页面挂载时预加载全部数字与提示语 WAV,后端继续返回叫号数据/文本,前端按文本映射片段播放。

验证:前端 66 项测试通过,生产构建通过。
2026-08-01 12:06:01 +08:00
01d261e893 优化:公屏叫号默认播放并移除声音控制
需求:服务端下发音频已验证可用,公屏无需再显示开启声音按钮和状态提示。

实现:移除声音控制组件与首次点击门槛,新叫号到达后直接播放内置音频队列。

验证:前端 65 项测试通过,生产构建通过。
2026-08-01 11:40:58 +08:00
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
40 changed files with 568 additions and 246 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -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,32 @@
import { describe, expect, it } from "vitest";
import { announcementAudioQueue } from "./useAudioAnnouncements";
const inlineAudio = (source: string) => source.startsWith("data:audio/wav;base64,");
describe("announcementAudioQueue", () => {
it("忽略项目名并把单号码播报素材重复三遍", () => {
const queue = announcementAudioQueue(["东门观光车,请零零零一六号,前往入口。"]);
const once = queue.slice(0, 9);
expect(once).toHaveLength(9);
expect(once.every(inlineAudio)).toBe(true);
expect(once[1]).toBe(once[2]);
expect(once[2]).toBe(once[3]);
expect(queue).toEqual([...once, ...once, ...once]);
});
it("支持连续号码范围", () => {
const queue = announcementAudioQueue(["请一七号至一九号,前往入口。"]);
expect(queue.slice(0, 9).every(inlineAudio)).toBe(true);
expect(queue[1]).not.toBe(queue[2]);
expect(queue[3]).not.toBe(queue[4]);
});
it("音频素材不使用独立 HTTP 路径", () => {
const queue = announcementAudioQueue(["请一号,前往入口。"]);
expect(queue).not.toContain("/audio/queue-voice/please.wav");
expect(queue.every(inlineAudio)).toBe(true);
});
});

View File

@@ -0,0 +1,136 @@
import { useCallback, useEffect, useRef } from "react";
import digit0Audio from "../assets/queue-voice/digit-0.wav?inline";
import digit1Audio from "../assets/queue-voice/digit-1.wav?inline";
import digit2Audio from "../assets/queue-voice/digit-2.wav?inline";
import digit3Audio from "../assets/queue-voice/digit-3.wav?inline";
import digit4Audio from "../assets/queue-voice/digit-4.wav?inline";
import digit5Audio from "../assets/queue-voice/digit-5.wav?inline";
import digit6Audio from "../assets/queue-voice/digit-6.wav?inline";
import digit7Audio from "../assets/queue-voice/digit-7.wav?inline";
import digit8Audio from "../assets/queue-voice/digit-8.wav?inline";
import digit9Audio from "../assets/queue-voice/digit-9.wav?inline";
import entranceAudio from "../assets/queue-voice/entrance.wav?inline";
import gapAudio from "../assets/queue-voice/gap.wav?inline";
import numberAudio from "../assets/queue-voice/number.wav?inline";
import pleaseAudio from "../assets/queue-voice/please.wav?inline";
import throughAudio from "../assets/queue-voice/through.wav?inline";
const ANNOUNCEMENT_REPEAT_COUNT = 3;
const GAP_AUDIO = gapAudio;
const DIGIT_AUDIO: Record<string, string> = {
: digit0Audio,
: digit1Audio,
: digit2Audio,
: digit3Audio,
: digit4Audio,
: digit5Audio,
: digit6Audio,
: digit7Audio,
: digit8Audio,
: digit9Audio,
};
const PHRASE_AUDIO = [
["前往入口", entranceAudio],
["请", pleaseAudio],
["号", numberAudio],
["至", throughAudio],
] 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 playQueue = useCallback((sources: string[]) => {
const audio = audioRef.current;
if (!audio || !sources.length) return;
const playbackId = ++playbackIdRef.current;
audio.pause();
audio.onended = null;
audio.onerror = null;
let cursor = 0;
const fail = () => {
if (playbackIdRef.current !== playbackId) return;
audio.onended = null;
audio.onerror = null;
};
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.catch(fail);
}
} catch {
fail();
}
};
playNext();
}, []);
const announce = useCallback((announcements: string[]) => {
if (!announcements.length) return;
const queue = announcementAudioQueue(announcements);
if (queue.length) playQueue(queue);
}, [playQueue]);
useEffect(() => () => {
playbackIdRef.current += 1;
const audio = audioRef.current;
if (!audio) return;
audio.pause();
audio.onended = null;
audio.onerror = null;
}, []);
return { announce, audioRef };
}

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

@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { pollingResource } = vi.hoisted(() => ({
@@ -48,6 +48,7 @@ vi.mock("../hooks/usePollingResource", () => ({
}));
import { DisplayPage, forecastRows } from "./DisplayPage";
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
describe("DisplayPage", () => {
beforeEach(() => {
@@ -94,33 +95,17 @@ 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;
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" });
it("新批次出现时无需人工开启即可连续播报三次叫号内容", () => {
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();
});
expect(screen.getByText("叫号声音已开启")).toBeVisible();
speak.mockClear();
cancel.mockClear();
const { container, rerender } = render(<DisplayPage />);
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
pollingResource.data.current_batch = {
batch_number: 3,
@@ -134,14 +119,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

@@ -2,9 +2,8 @@ import { useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { api } from "../api";
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 +11,7 @@ export { forecastRows } from "../lib/display";
export function DisplayPage() {
const { token = "" } = useParams();
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
const { announce, audioRef } = useAudioAnnouncements();
const resource = usePollingResource((signal) => api.display(token, signal), {
enabled: Boolean(token),
intervalMs: 3_000,
@@ -70,7 +69,7 @@ export function DisplayPage() {
return (
<main className="single-display-screen">
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
<audio ref={audioRef} preload="auto" aria-hidden="true" />
{(resource.offline || stale || resource.error) ? (
<div className="display-alert" role="status">
<strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong>

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { displayOverview, pollingResource } = vi.hoisted(() => ({
@@ -38,6 +38,7 @@ vi.mock("../hooks/usePollingResource", () => ({
}));
import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage";
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
describe("PublicDisplayCenterPage", () => {
beforeEach(() => {
@@ -62,28 +63,17 @@ 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("新叫号批次出现时无需人工开启即可连续三次播报号码", () => {
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?.());
expect(screen.getByText("叫号声音已开启")).toBeVisible();
speak.mockClear();
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
pollingResource.data = {
...pollingResource.data,
@@ -104,12 +94,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,8 @@
import { useEffect, useRef } from "react";
import { api } from "../api";
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 +12,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
}
export function PublicDisplayCenterPage() {
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
const { announce, audioRef } = 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 +28,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 +37,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} />
<audio ref={audioRef} preload="auto" aria-hidden="true" />
<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>
)}