实现叫号播报并调整现场业务规则
需求描述:发布屏在员工叫号后播放语音;同场次手机号被叫号后可重新取号;登录连续失败限制调整为10次。 实现思路:发布屏按新批次去重触发中文语音;重复手机号仅拦截WAITING号码;登录限流使用10次阈值,并补充前后端回归测试与接口说明。
This commit is contained in:
@@ -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
|
larger than the requested target. Each mode has a separate project-level
|
||||||
anti-mistouch maximum.
|
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
|
`DUPLICATE_PHONE`; repeat with the same request body except
|
||||||
`allow_duplicate: true` and a new idempotency key after the employee confirms.
|
`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
|
`GET /api/public/projects` returns projects currently open for visitor
|
||||||
self-service, including each project's allowed party-size range.
|
self-service, including each project's allowed party-size range.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const loginFailureLimit = 10
|
||||||
|
|
||||||
type loginAttempt struct {
|
type loginAttempt struct {
|
||||||
failures int
|
failures int
|
||||||
windowStart time.Time
|
windowStart time.Time
|
||||||
@@ -93,7 +95,7 @@ func (l *loginLimiter) failure(key string) {
|
|||||||
attempt = loginAttempt{windowStart: now}
|
attempt = loginAttempt{windowStart: now}
|
||||||
}
|
}
|
||||||
attempt.failures++
|
attempt.failures++
|
||||||
if attempt.failures >= 5 {
|
if attempt.failures >= loginFailureLimit {
|
||||||
attempt.blockedTill = now.Add(10 * time.Minute)
|
attempt.blockedTill = now.Add(10 * time.Minute)
|
||||||
}
|
}
|
||||||
l.attempts[key] = attempt
|
l.attempts[key] = attempt
|
||||||
|
|||||||
@@ -24,3 +24,21 @@ func TestQueryLimiterResetsAfterWindow(t *testing.T) {
|
|||||||
t.Fatal("query should be allowed after the window resets")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,29 @@ func TestPublicCreateTicketIgnoresDuplicateFromEndedSession(t *testing.T) {
|
|||||||
t.Fatalf("current-session duplicate status = %d, want 409 DUPLICATE_PHONE; body = %s",
|
t.Fatalf("current-session duplicate status = %d, want 409 DUPLICATE_PHONE; body = %s",
|
||||||
duplicateRecorder.Code, duplicateRecorder.Body.String())
|
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) {
|
func TestInternalPhoneLookupUsesLatestActiveSessionPerProject(t *testing.T) {
|
||||||
|
|||||||
@@ -296,8 +296,8 @@ func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, ac
|
|||||||
phoneDigest := s.cipher.Digest(phone)
|
phoneDigest := s.cipher.Digest(phone)
|
||||||
var duplicates []model.QueueTicket
|
var duplicates []model.QueueTicket
|
||||||
if err := tx.Select("id", "display_number", "status").
|
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,
|
Where("project_id = ? AND queue_session_id = ? AND phone_hmac = ? AND status = ?",
|
||||||
[]string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}).
|
projectID, session.ID, phoneDigest, model.TicketWaiting).
|
||||||
Order("joined_at ASC").Find(&duplicates).Error; err != nil {
|
Order("joined_at ASC").Find(&duplicates).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
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" }) }));
|
const { pollingResource } = vi.hoisted(() => ({
|
||||||
vi.mock("../api", () => ({ api: { display: vi.fn() } }));
|
pollingResource: {
|
||||||
vi.mock("../hooks/usePollingResource", () => ({
|
|
||||||
usePollingResource: () => ({
|
|
||||||
data: {
|
data: {
|
||||||
project_name: "示范项目",
|
project_name: "示范项目",
|
||||||
status: "RUNNING",
|
status: "RUNNING",
|
||||||
@@ -12,6 +10,7 @@ vi.mock("../hooks/usePollingResource", () => ({
|
|||||||
waiting_ticket_count: 0,
|
waiting_ticket_count: 0,
|
||||||
waiting_people_count: 0,
|
waiting_people_count: 0,
|
||||||
experienced_people: 12,
|
experienced_people: 12,
|
||||||
|
current_batch: null,
|
||||||
recent_batches: [
|
recent_batches: [
|
||||||
{
|
{
|
||||||
batch_number: 2,
|
batch_number: 2,
|
||||||
@@ -37,13 +36,26 @@ vi.mock("../hooks/usePollingResource", () => ({
|
|||||||
offline: false,
|
offline: false,
|
||||||
lastClientSuccessAt: new Date().toISOString(),
|
lastClientSuccessAt: new Date().toISOString(),
|
||||||
refresh: vi.fn(),
|
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";
|
import { DisplayPage, forecastRows } from "./DisplayPage";
|
||||||
|
|
||||||
describe("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", () => {
|
it("renders public batches without requiring private batch IDs", () => {
|
||||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
@@ -76,4 +88,41 @@ describe("DisplayPage", () => {
|
|||||||
{ range: "00071–00075", wait: "60–90 分钟" },
|
{ 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(<DisplayPage />);
|
||||||
|
|
||||||
|
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(<DisplayPage />);
|
||||||
|
rerender(<DisplayPage />);
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalledOnce();
|
||||||
|
expect(speak).toHaveBeenCalledOnce();
|
||||||
|
expect(speak.mock.calls[0][0]).toMatchObject({
|
||||||
|
text: "请零零零零五号至零零零零六号,前往入口。",
|
||||||
|
lang: "zh-CN",
|
||||||
|
rate: 0.9,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { usePollingResource } from "../hooks/usePollingResource";
|
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_INTERVAL_SIZE = 30;
|
||||||
const FORECAST_PAGE_SIZE = 4;
|
const FORECAST_PAGE_SIZE = 4;
|
||||||
|
const SPOKEN_DIGITS: Record<string, string> = {
|
||||||
|
"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) {
|
export function forecastRows(batch: CallBatchDto | null | undefined, waitingCount: number, estimatedWait: unknown) {
|
||||||
const called = batchNumbers(batch);
|
const called = batchNumbers(batch);
|
||||||
@@ -51,6 +77,29 @@ export function DisplayPage() {
|
|||||||
const freshnessTime = resource.lastClientSuccessAt;
|
const freshnessTime = resource.lastClientSuccessAt;
|
||||||
const stale = Boolean(data) && isTimestampStale(freshnessTime, 15_000);
|
const stale = Boolean(data) && isTimestampStale(freshnessTime, 15_000);
|
||||||
const current = data?.current_batch;
|
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 waitingTicketCount = data?.waiting_ticket_count ?? data?.waiting_count ?? 0;
|
||||||
const waitingPeopleCount = data?.waiting_people_count ?? 0;
|
const waitingPeopleCount = data?.waiting_people_count ?? 0;
|
||||||
const forecasts = data ? forecastRows(current, waitingTicketCount, data.estimated_wait) : [];
|
const forecasts = data ? forecastRows(current, waitingTicketCount, data.estimated_wait) : [];
|
||||||
|
|||||||
Reference in New Issue
Block a user