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) : [];