diff --git a/server/README.md b/server/README.md
index 259f925..8fd1f0a 100644
--- a/server/README.md
+++ b/server/README.md
@@ -40,9 +40,11 @@ it never splits or skips a ticket and rejects when the first ticket alone is
larger than the requested target. Each mode has a separate project-level
anti-mistouch maximum.
-If a phone already has active tickets, ticket creation returns
+If a phone already has waiting tickets in the current queue session, ticket creation returns
`DUPLICATE_PHONE`; repeat with the same request body except
`allow_duplicate: true` and a new idempotency key after the employee confirms.
+Once the earlier ticket has been called, the same phone can take a new ticket
+without duplicate confirmation.
`GET /api/public/projects` returns projects currently open for visitor
self-service, including each project's allowed party-size range.
diff --git a/server/internal/httpapi/limiter.go b/server/internal/httpapi/limiter.go
index 46f70c4..fdbec0e 100644
--- a/server/internal/httpapi/limiter.go
+++ b/server/internal/httpapi/limiter.go
@@ -5,6 +5,8 @@ import (
"time"
)
+const loginFailureLimit = 10
+
type loginAttempt struct {
failures int
windowStart time.Time
@@ -93,7 +95,7 @@ func (l *loginLimiter) failure(key string) {
attempt = loginAttempt{windowStart: now}
}
attempt.failures++
- if attempt.failures >= 5 {
+ if attempt.failures >= loginFailureLimit {
attempt.blockedTill = now.Add(10 * time.Minute)
}
l.attempts[key] = attempt
diff --git a/server/internal/httpapi/limiter_test.go b/server/internal/httpapi/limiter_test.go
index c26861f..71bd1b6 100644
--- a/server/internal/httpapi/limiter_test.go
+++ b/server/internal/httpapi/limiter_test.go
@@ -24,3 +24,21 @@ func TestQueryLimiterResetsAfterWindow(t *testing.T) {
t.Fatal("query should be allowed after the window resets")
}
}
+
+func TestLoginLimiterBlocksAfterTenFailures(t *testing.T) {
+ now := time.Unix(100, 0)
+ limiter := newLoginLimiter(func() time.Time { return now })
+ const key = "staff|127.0.0.1"
+
+ for attempt := 1; attempt < 10; attempt++ {
+ limiter.failure(key)
+ if allowed, _ := limiter.allow(key); !allowed {
+ t.Fatalf("login should remain allowed after %d failures", attempt)
+ }
+ }
+
+ limiter.failure(key)
+ if allowed, retry := limiter.allow(key); allowed || retry != 10*time.Minute {
+ t.Fatalf("login should be locked for 10 minutes after 10 failures, got allowed=%v retry=%s", allowed, retry)
+ }
+}
diff --git a/server/internal/httpapi/public_integration_test.go b/server/internal/httpapi/public_integration_test.go
index 30b60b0..872ecdf 100644
--- a/server/internal/httpapi/public_integration_test.go
+++ b/server/internal/httpapi/public_integration_test.go
@@ -131,6 +131,29 @@ func TestPublicCreateTicketIgnoresDuplicateFromEndedSession(t *testing.T) {
t.Fatalf("current-session duplicate status = %d, want 409 DUPLICATE_PHONE; body = %s",
duplicateRecorder.Code, duplicateRecorder.Body.String())
}
+
+ calledAt := now
+ result := db.Model(&model.QueueTicket{}).
+ Where("project_id = ? AND queue_session_id = ? AND phone_hmac = ? AND status = ?",
+ projectID, currentSession.ID, phoneHMAC, model.TicketWaiting).
+ Updates(map[string]any{"status": model.TicketCalled, "called_at": calledAt, "updated_at": calledAt})
+ if result.Error != nil {
+ t.Fatal(result.Error)
+ }
+ if result.RowsAffected != 1 {
+ t.Fatalf("called tickets = %d, want 1", result.RowsAffected)
+ }
+
+ afterCallRecorder := httptest.NewRecorder()
+ afterCallRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets",
+ strings.NewReader(`{"phone":"13800138000","honorific":"游客","party_size":1,"allow_duplicate":false}`))
+ afterCallRequest.Header.Set("Idempotency-Key", "public-after-call-"+uuid.NewString())
+
+ server.Handler().ServeHTTP(afterCallRecorder, afterCallRequest)
+
+ if afterCallRecorder.Code != http.StatusCreated {
+ t.Fatalf("after-call status = %d, want 201; body = %s", afterCallRecorder.Code, afterCallRecorder.Body.String())
+ }
}
func TestInternalPhoneLookupUsesLatestActiveSessionPerProject(t *testing.T) {
diff --git a/server/internal/httpapi/staff.go b/server/internal/httpapi/staff.go
index a88107e..e08c379 100644
--- a/server/internal/httpapi/staff.go
+++ b/server/internal/httpapi/staff.go
@@ -296,8 +296,8 @@ func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, ac
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 IN ?", projectID, session.ID, phoneDigest,
- []string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}).
+ Where("project_id = ? AND queue_session_id = ? AND phone_hmac = ? AND status = ?",
+ projectID, session.ID, phoneDigest, model.TicketWaiting).
Order("joined_at ASC").Find(&duplicates).Error; err != nil {
return err
}
diff --git a/web/src/pages/DisplayPage.test.tsx b/web/src/pages/DisplayPage.test.tsx
index 636a0fb..d4aeeae 100644
--- a/web/src/pages/DisplayPage.test.tsx
+++ b/web/src/pages/DisplayPage.test.tsx
@@ -1,10 +1,8 @@
import { render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-vi.mock("react-router-dom", () => ({ useParams: () => ({ token: "display-token" }) }));
-vi.mock("../api", () => ({ api: { display: vi.fn() } }));
-vi.mock("../hooks/usePollingResource", () => ({
- usePollingResource: () => ({
+const { pollingResource } = vi.hoisted(() => ({
+ pollingResource: {
data: {
project_name: "示范项目",
status: "RUNNING",
@@ -12,6 +10,7 @@ vi.mock("../hooks/usePollingResource", () => ({
waiting_ticket_count: 0,
waiting_people_count: 0,
experienced_people: 12,
+ current_batch: null,
recent_batches: [
{
batch_number: 2,
@@ -37,13 +36,26 @@ vi.mock("../hooks/usePollingResource", () => ({
offline: false,
lastClientSuccessAt: new Date().toISOString(),
refresh: vi.fn(),
- }),
+ } as any,
+}));
+
+vi.mock("react-router-dom", () => ({ useParams: () => ({ token: "display-token" }) }));
+vi.mock("../api", () => ({ api: { display: vi.fn() } }));
+vi.mock("../hooks/usePollingResource", () => ({
+ usePollingResource: () => pollingResource,
}));
import { DisplayPage, forecastRows } from "./DisplayPage";
describe("DisplayPage", () => {
- afterEach(() => vi.restoreAllMocks());
+ beforeEach(() => {
+ pollingResource.data.current_batch = null;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
it("renders public batches without requiring private batch IDs", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -76,4 +88,41 @@ describe("DisplayPage", () => {
{ range: "00071–00075", wait: "60–90 分钟" },
]);
});
+
+ it("新批次出现时只播报一次叫号内容", () => {
+ const speak = vi.fn();
+ const cancel = vi.fn();
+ class SpeechSynthesisUtteranceMock {
+ text: string;
+ lang = "";
+ rate = 1;
+
+ constructor(text: string) {
+ this.text = text;
+ }
+ }
+ vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
+ vi.stubGlobal("speechSynthesis", { speak, cancel });
+ const { rerender } = render();
+
+ pollingResource.data.current_batch = {
+ batch_number: 3,
+ status: "CALLED",
+ called_at: "2026-07-10T09:05:00Z",
+ tickets: [
+ { ticket_number: "00005", status: "CALLED", party_size: 2 },
+ { ticket_number: "00006", status: "CALLED", party_size: 1 },
+ ],
+ };
+ rerender();
+ rerender();
+
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(speak).toHaveBeenCalledOnce();
+ expect(speak.mock.calls[0][0]).toMatchObject({
+ text: "请零零零零五号至零零零零六号,前往入口。",
+ lang: "zh-CN",
+ rate: 0.9,
+ });
+ });
});
diff --git a/web/src/pages/DisplayPage.tsx b/web/src/pages/DisplayPage.tsx
index b8d5524..5b88945 100644
--- a/web/src/pages/DisplayPage.tsx
+++ b/web/src/pages/DisplayPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { api } from "../api";
import { usePollingResource } from "../hooks/usePollingResource";
@@ -20,6 +20,32 @@ function waitRange(value: unknown): { min: number; max: number } | null {
const FORECAST_INTERVAL_SIZE = 30;
const FORECAST_PAGE_SIZE = 4;
+const SPOKEN_DIGITS: Record = {
+ "0": "零",
+ "1": "一",
+ "2": "二",
+ "3": "三",
+ "4": "四",
+ "5": "五",
+ "6": "六",
+ "7": "七",
+ "8": "八",
+ "9": "九",
+};
+
+function spokenTicketNumber(number: string): string {
+ return Array.from(number.trim(), (character) => SPOKEN_DIGITS[character] ?? character).join("");
+}
+
+function callAnnouncement(batch?: CallBatchDto | null): string | null {
+ const numbers = batchNumbers(batch);
+ if (!numbers.length) return null;
+ const first = spokenTicketNumber(numbers[0]);
+ const calledNumbers = numbers.length === 1
+ ? `${first}号`
+ : `${first}号至${spokenTicketNumber(numbers[numbers.length - 1])}号`;
+ return `请${calledNumbers},前往入口。`;
+}
export function forecastRows(batch: CallBatchDto | null | undefined, waitingCount: number, estimatedWait: unknown) {
const called = batchNumbers(batch);
@@ -51,6 +77,29 @@ export function DisplayPage() {
const freshnessTime = resource.lastClientSuccessAt;
const stale = Boolean(data) && isTimestampStale(freshnessTime, 15_000);
const current = data?.current_batch;
+ const currentCallKey = current ? `${current.batch_number}:${current.called_at ?? ""}` : null;
+ const currentAnnouncement = callAnnouncement(current);
+ const hasSnapshot = Boolean(data);
+ const lastCallRef = useRef<{ token: string; key: string | null } | null>(null);
+ useEffect(() => {
+ if (!hasSnapshot) return;
+ const previous = lastCallRef.current;
+ lastCallRef.current = { token, key: currentCallKey };
+ if (
+ !previous
+ || previous.token !== token
+ || previous.key === currentCallKey
+ || !currentAnnouncement
+ || !("speechSynthesis" in window)
+ || typeof SpeechSynthesisUtterance === "undefined"
+ ) return;
+
+ const utterance = new SpeechSynthesisUtterance(currentAnnouncement);
+ utterance.lang = "zh-CN";
+ utterance.rate = 0.9;
+ window.speechSynthesis.cancel();
+ window.speechSynthesis.speak(utterance);
+ }, [currentAnnouncement, currentCallKey, hasSnapshot, token]);
const waitingTicketCount = data?.waiting_ticket_count ?? data?.waiting_count ?? 0;
const waitingPeopleCount = data?.waiting_people_count ?? 0;
const forecasts = data ? forecastRows(current, waitingTicketCount, data.estimated_wait) : [];