修复账号禁用及队列场次状态规则
问题与需求:后台禁用员工后旧会话仍可继续访问;实时队列跨天误读昨日场次;项目暂停后需禁止取号但允许叫号。 修复思路:账号权限变更时撤销会话并同步前端登录态;实时查询统一按项目时区当天场次过滤;拆分取号与叫号的状态校验,并补充前后端及 PostgreSQL 回归测试。
This commit is contained in:
@@ -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>
|
||||
|
||||
48
web/src/auth/AuthContext.test.tsx
Normal file
48
web/src/auth/AuthContext.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user