修复账号禁用及队列场次状态规则

问题与需求:后台禁用员工后旧会话仍可继续访问;实时队列跨天误读昨日场次;项目暂停后需禁止取号但允许叫号。

修复思路:账号权限变更时撤销会话并同步前端登录态;实时查询统一按项目时区当天场次过滤;拆分取号与叫号的状态校验,并补充前后端及 PostgreSQL 回归测试。
This commit is contained in:
2026-07-30 12:02:05 +08:00
parent 0e1ac400dc
commit 17d296263c
14 changed files with 752 additions and 54 deletions

View File

@@ -21,6 +21,13 @@ import type {
} from "./types";
const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? "";
export const AUTH_INVALID_EVENT = "queue:auth-invalid";
function authPortalForPath(path: string): "staff" | "admin" | null {
if (path.startsWith("/api/staff/")) return "staff";
if (path.startsWith("/api/admin/")) return "admin";
return null;
}
export class ApiError extends Error {
readonly status: number;
@@ -59,6 +66,10 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
: await response.text().catch(() => "");
if (!response.ok) {
const portal = authPortalForPath(path);
if (response.status === 401 && portal && !path.endsWith("/auth/login")) {
window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT, { detail: { portal } }));
}
const record = body && typeof body === "object" ? (body as Record<string, unknown>) : null;
const nestedError = record?.error && typeof record.error === "object"
? record.error as Record<string, unknown>

View File

@@ -0,0 +1,48 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { api } from "../api";
import { AuthProvider, useAuth } from "./AuthContext";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function AuthProbe() {
const { user } = useAuth();
return (
<>
<span>{user ? user.username : "signed-out"}</span>
<button type="button" onClick={() => void api.staffProjects().catch(() => undefined)}>
load protected resource
</button>
</>
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("AuthProvider session invalidation", () => {
it("clears the current staff when a protected staff request returns 401", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse({
user: { id: "staff-1", username: "staff01", display_name: "Staff", role: "STAFF" },
projects: [],
}))
.mockResolvedValueOnce(jsonResponse({
error: { code: "SESSION_INVALID", message: "Session expired" },
}, 401));
vi.stubGlobal("fetch", fetchMock);
render(<AuthProvider portal="staff"><AuthProbe /></AuthProvider>);
expect(await screen.findByText("staff01")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "load protected resource" }));
await waitFor(() => expect(screen.getByText("signed-out")).toBeVisible());
});
});

View File

@@ -1,5 +1,5 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { ApiError, api } from "../api";
import { AUTH_INVALID_EVENT, ApiError, api } from "../api";
import type { AuthPayload, ProjectDto, UserDto } from "../types";
interface AuthContextValue {
@@ -39,6 +39,19 @@ export function AuthProvider({ children, portal }: { children: ReactNode; portal
}
};
useEffect(() => {
const handleInvalidSession = (event: Event) => {
const invalidPortal = (event as CustomEvent<{ portal?: AuthPortal }>).detail?.portal;
if (invalidPortal !== portal) return;
setUser(null);
setProjects([]);
setError(null);
setLoading(false);
};
window.addEventListener(AUTH_INVALID_EVENT, handleInvalidSession);
return () => window.removeEventListener(AUTH_INVALID_EVENT, handleInvalidSession);
}, [portal]);
useEffect(() => {
void refresh();
}, [portal]);

View File

@@ -2,6 +2,8 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
const staffPageTestState = vi.hoisted(() => ({ projectStatus: "RUNNING" }));
vi.mock("../components/AppShell", () => ({
AppShell: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
<div data-testid="app-shell" data-variant={variant}>{children}</div>
@@ -11,7 +13,7 @@ vi.mock("../hooks/usePollingResource", () => ({
usePollingResource: (_load: unknown, options: { intervalMs: number }) => options.intervalMs === 60_000
? {
data: { projects: [
{ id: "project-1", name: "东门观光车", status: "RUNNING", batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
{ id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
{ id: "project-2", name: "西门观光车", status: "RUNNING", batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
] },
loading: false,
@@ -23,7 +25,7 @@ vi.mock("../hooks/usePollingResource", () => ({
}
: {
data: {
project: { id: "project-1", name: "东门观光车", status: "RUNNING", batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
project: { id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
revision: 8,
waiting: Array.from({ length: 25 }, (_, index) => ({
id: `waiting-${index + 1}`,
@@ -82,7 +84,21 @@ function renderScene(path: string) {
}
describe("StaffPage scene-focused H5", () => {
beforeEach(() => sessionStorage.setItem("scenic-current-project", "project-1"));
beforeEach(() => {
staffPageTestState.projectStatus = "RUNNING";
sessionStorage.setItem("scenic-current-project", "project-1");
});
it("allows calling but blocks ticket creation while the project is paused", () => {
staffPageTestState.projectStatus = "PAUSED";
renderScene("/staff");
expect(screen.getByRole("button", { name: "快速叫下一个号" })).toBeEnabled();
expect(screen.queryByText("当前项目未开放叫号")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("link", { name: "取号" }));
expect(screen.getByRole("button", { name: "创建排队号码" })).toBeDisabled();
expect(screen.getByText("当前不可取号")).toBeInTheDocument();
});
it("keeps the call scene focused on the active batch and the primary call action", () => {
renderScene("/staff");

View File

@@ -142,7 +142,9 @@ export function StaffPage() {
const maxPartySize = selectedProject?.max_party_size ?? minPartySize;
const freshnessTime = queue?.last_success_at ?? queue?.server_time ?? queueResource.lastClientSuccessAt;
const stale = Boolean(queue) && isTimestampStale(freshnessTime, 20_000);
const writeBlocked = queueResource.offline || stale || !isProjectRunning(selectedProject?.status);
const callBlocked = queueResource.offline || stale ||
(selectedProject?.status !== "RUNNING" && selectedProject?.status !== "PAUSED");
const takeTicketBlocked = queueResource.offline || stale || !isProjectRunning(selectedProject?.status);
const waitingTicketCount = Math.max(0, Number(queue?.metrics.waiting_ticket_count ?? queue?.metrics.waiting_count) || 0);
const waitingPeopleCount = Math.max(0, Number(queue?.metrics.waiting_people_count) || 0);
const waitingTickets = queue?.waiting ?? [];
@@ -153,11 +155,11 @@ export function StaffPage() {
const latestCalledNumber = calledTickets[calledTickets.length - 1]?.ticket_number ?? "暂无";
const nextWaitingNumber = queue?.waiting[0]?.ticket_number ?? "暂无";
const nextWaitingPeopleCount = queue?.waiting[0]?.party_size;
const writeBlockedReason = queueResource.offline
const callBlockedReason = queueResource.offline
? "网络连接中断,暂不可叫号"
: stale
? "队列数据更新延迟,请刷新后重试"
: !isProjectRunning(selectedProject?.status)
: callBlocked
? "当前项目未开放叫号"
: waitingTicketCount === 0
? "当前没有等待号码"
@@ -447,21 +449,21 @@ export function StaffPage() {
<h2 id="staff-call-actions-title" className="staff-call-actions__title">/</h2>
<div className="staff-call-controls">
{supportsTicketCall ? <div className="staff-call-mode" aria-label="按号码叫号">
<button className="button button--primary button--call" onClick={() => void callNext("TICKET", 1)} disabled={busyAction === "call-next" || writeBlocked || waitingTicketCount === 0}>
<button className="button button--primary button--call" onClick={() => void callNext("TICKET", 1)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}>
{busyAction === "call-next" ? "正在叫号" : "快速叫下一个号"}
</button>
<div className="staff-batch-action">
<label className="field"><span></span><input aria-label="按号码叫号数量" type="number" min="1" max={selectedProject?.max_call_ticket_count ?? 100} value={ticketCallCount} onChange={(event) => setTicketCallCount(Number(event.target.value) || 1)} /></label>
<button className="button button--secondary" onClick={() => void callNext("TICKET", ticketCallCount)} disabled={busyAction === "call-next" || writeBlocked || waitingTicketCount === 0}></button>
<button className="button button--secondary" onClick={() => void callNext("TICKET", ticketCallCount)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}></button>
</div>
</div> : null}
{supportsPeopleCall ? <div className="staff-call-mode" aria-label="按人数叫号">
<div className="staff-batch-action">
<label className="field"><span></span><input aria-label="按人数叫号数量" type="number" min="1" max={selectedProject?.max_call_people_count ?? 100} value={peopleCallCount} onChange={(event) => setPeopleCallCount(Number(event.target.value) || 1)} /></label>
<button className="button button--secondary" onClick={() => void callNext("PEOPLE", peopleCallCount)} disabled={busyAction === "call-next" || writeBlocked || waitingTicketCount === 0}></button>
<button className="button button--secondary" onClick={() => void callNext("PEOPLE", peopleCallCount)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}></button>
</div>
</div> : null}
{writeBlockedReason ? <p className="staff-action-reason" role="status">{writeBlockedReason}</p> : null}
{callBlockedReason ? <p className="staff-action-reason" role="status">{callBlockedReason}</p> : null}
</div>
</section>
</div>
@@ -584,8 +586,8 @@ export function StaffPage() {
</div>
</fieldset>
</div>
{writeBlocked ? <span className="sr-only" id="create-ticket-state"></span> : null}
<button className="button button--primary button--wide" type="submit" aria-describedby={writeBlocked ? "create-ticket-state" : undefined} disabled={busyAction === "create-ticket" || writeBlocked}>
{takeTicketBlocked ? <span className="sr-only" id="create-ticket-state"></span> : null}
<button className="button button--primary button--wide" type="submit" aria-describedby={takeTicketBlocked ? "create-ticket-state" : undefined} disabled={busyAction === "create-ticket" || takeTicketBlocked}>
{busyAction === "create-ticket"
? "正在创建排队单"
: confirmDuplicatePhone