R65 A1-A6:Session 稳定性与 409 资源错误修复
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run

这个提交包含在:
2026-04-16 20:23:54 +08:00
父节点 bc6e4955b3
当前提交 8353efcbd7
共修改 7 个文件,包含 425 行新增和 11 行删除
+45 -8
查看文件
@@ -9,7 +9,14 @@ import {
useState,
} from "react";
import { configureHttpAuth } from "../api/http";
import type { AuthState, LoginResponse, MeResponse, ProfileCompleteness } from "../api/types";
import {
isApiError,
isSessionExpiredError,
type AuthState,
type LoginResponse,
type MeResponse,
type ProfileCompleteness,
} from "../api/types";
import { changePassword as changePasswordApi } from "../api/auth";
import { clearPwaRuntimeCaches } from "../pwa";
import {
@@ -76,6 +83,16 @@ function clearSessionHint() {
document.cookie = "has_session=; path=/; max-age=0";
}
function wait(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function isTerminalBootstrapError(error: unknown) {
return isSessionExpiredError(error) || (isApiError(error) && error.status === 401);
}
function normalizeProfileCompleteness(value: unknown): ProfileCompleteness {
if (!value || typeof value !== "object") {
return DEFAULT_PROFILE_COMPLETENESS;
@@ -353,19 +370,39 @@ export function AuthProvider({ children }: PropsWithChildren) {
if (!bootstrapPromiseRef.current) {
bootstrapPromiseRef.current = (async () => {
try {
const refreshed = await runRefresh();
const completeBootstrap = (refreshed: LoginResponse | null) => {
if (refreshed?.expiresIn) {
scheduleProactiveRefresh(refreshed.expiresIn);
}
return refreshed?.accessToken ?? null;
} catch {
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
activateOfflineSnapshot(offlineSnapshot);
};
try {
return completeBootstrap(await runRefresh());
} catch (error) {
if (isTerminalBootstrapError(error)) {
clearSession();
return null;
}
try {
await wait(1000);
return completeBootstrap(await runRefresh());
} catch (retryError) {
if (isTerminalBootstrapError(retryError)) {
clearSession();
return null;
}
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
activateOfflineSnapshot(offlineSnapshot);
return null;
}
clearSession();
return null;
}
clearSession();
return null;
} finally {
bootstrapPromiseRef.current = null;
}
+97 -1
查看文件
@@ -7,9 +7,105 @@ import { ErrorBoundary } from "./components/common/ErrorBoundary";
import { Toaster } from "./components/ui/sonner";
import { queryClient } from "./query/queryClient";
import { AppRouter } from "./router";
import { registerPwaIfEnabled } from "./pwa";
import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa";
import "./index.css";
const CHUNK_RELOAD_ATTEMPTED_KEY = "hyb_oa_chunk_reload_attempted";
const CHUNK_LOAD_ERROR_PATTERNS = [
"chunkloaderror",
"loading chunk",
"failed to fetch dynamically imported module",
"importing a module script failed",
"error loading dynamically imported module",
];
function extractErrorMessages(value: unknown): string[] {
if (!value) {
return [];
}
if (typeof value === "string") {
return [value];
}
if (value instanceof Error) {
return [value.name, value.message];
}
if (typeof value !== "object") {
return [];
}
const record = value as {
message?: unknown;
reason?: unknown;
payload?: unknown;
error?: unknown;
};
return [
...extractErrorMessages(record.message),
...extractErrorMessages(record.reason),
...extractErrorMessages(record.payload),
...extractErrorMessages(record.error),
];
}
function isChunkLoadFailure(value: unknown) {
return extractErrorMessages(value).some((message) => {
const normalized = message.toLowerCase();
return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
});
}
function installChunkLoadRecovery() {
if (typeof window === "undefined") {
return;
}
let reloadTriggered = false;
const reloadForChunkFailure = () => {
if (reloadTriggered || window.sessionStorage.getItem(CHUNK_RELOAD_ATTEMPTED_KEY)) {
return;
}
reloadTriggered = true;
window.sessionStorage.setItem(CHUNK_RELOAD_ATTEMPTED_KEY, "1");
void clearPwaRuntimeCaches()
.catch(() => undefined)
.finally(() => {
window.location.reload();
});
};
window.addEventListener("vite:preloadError", (event) => {
if (!isChunkLoadFailure(event.payload)) {
return;
}
event.preventDefault();
reloadForChunkFailure();
});
window.addEventListener("error", (event) => {
if (!isChunkLoadFailure(event.error ?? event.message)) {
return;
}
reloadForChunkFailure();
});
window.addEventListener("unhandledrejection", (event) => {
if (!isChunkLoadFailure(event.reason)) {
return;
}
event.preventDefault();
reloadForChunkFailure();
});
}
installChunkLoadRecovery();
registerPwaIfEnabled();
ReactDOM.createRoot(document.getElementById("root")!).render(
+36 -1
查看文件
@@ -5,6 +5,29 @@ const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i;
const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"];
const CLEARABLE_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime", "workbox-precache"];
function bindServiceWorkerUpdateCleanup(registration: ServiceWorkerRegistration | undefined) {
if (!canUseWindow() || !registration) {
return;
}
const watchInstallingWorker = (worker: ServiceWorker | null) => {
if (!worker) {
return;
}
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && window.navigator.serviceWorker.controller) {
void clearPwaRuntimeCaches();
}
});
};
watchInstallingWorker(registration.installing);
registration.addEventListener("updatefound", () => {
watchInstallingWorker(registration.installing);
});
}
export function registerPwaIfEnabled() {
if (import.meta.env.DEV) {
return;
@@ -14,13 +37,25 @@ export function registerPwaIfEnabled() {
return;
}
registerSW({
let updateServiceWorker: ((reloadPage?: boolean) => Promise<void>) | null = null;
updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
void clearPwaRuntimeCaches()
.catch(() => undefined)
.finally(() => {
void updateServiceWorker?.(true);
});
},
onOfflineReady() {
if (canUseWindow()) {
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
}
},
onRegisteredSW(_swScriptUrl, registration) {
bindServiceWorkerUpdateCleanup(registration);
},
});
}
+123 -1
查看文件
@@ -12,6 +12,66 @@ function envelope(data: unknown) {
};
}
function mockPasswordLogin(page: import("@playwright/test").Page) {
return Promise.all([
page.route("**/api/auth/sso/providers", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope([])),
});
}),
page.route("**/api/auth/wecom/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({
enabled: false,
entryEnabled: false,
oauthEnabled: false,
corpId: "",
agentId: "",
loginUrl: null,
bindEnabled: false,
defaultReturnTo: "/portal",
status: "disabled",
statusReasons: ["disabled"],
})),
});
}),
page.route("**/api/auth/login", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({
accessToken: "test-access-token",
expiresIn: 900,
mustChangePassword: false,
})),
});
}),
]);
}
function mockNotificationList(page: import("@playwright/test").Page) {
return page.route((url) => {
return url.pathname === "/api/notifications";
}, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({
page: 1,
pageSize: 1,
total: 0,
unreadOnly: true,
unreadCount: 0,
items: [],
})),
});
});
}
function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) {
return page.route("**/api/me", async (route) => {
await route.fulfill({
@@ -211,9 +271,11 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
}
test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
await mockPasswordLogin(page);
await mockNavigation(page, "employee");
await mockPortalHome(page, "employee");
await mockAttendanceToday(page);
await mockNotificationList(page);
await mockMe(page, ["employee"], "Employee");
await login(page, EMPLOYEE);
@@ -241,9 +303,11 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
});
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
await mockPasswordLogin(page);
await mockNavigation(page, "manager");
await mockPortalHome(page, "manager");
await mockAttendanceToday(page);
await mockNotificationList(page);
await mockMe(page, ["admin"], "Admin");
await login(page, ADMIN);
@@ -274,25 +338,81 @@ test("Portal 新员工引导先显示欢迎弹窗,再显示快速上手清单"
window.localStorage.removeItem("hyb_oa_getting_started_dismissed");
});
await mockPasswordLogin(page);
await mockNavigation(page, "employee");
await mockPortalHome(page, "employee");
await mockAttendanceToday(page);
await mockNotificationList(page);
await mockMe(page, ["employee"], "Employee");
await login(page, EMPLOYEE);
await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).toBeVisible();
await page.getByTestId(TEST_IDS.onboarding.welcomeClose).click();
await page.getByTestId(TEST_IDS.onboarding.welcomeClose).evaluate((element: HTMLButtonElement) => {
element.click();
});
await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).not.toBeVisible();
await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).toBeVisible();
await page.getByTestId(TEST_IDS.onboarding.gettingStartedDismiss).click();
await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).not.toBeVisible();
});
test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录态", async ({ page }) => {
let refreshAttempts = 0;
await page.addInitScript(() => {
document.cookie = "has_session=1; path=/";
window.sessionStorage.removeItem("hyb_oa_chunk_reload_attempted");
});
await page.route("**/api/auth/refresh", async (route) => {
refreshAttempts += 1;
if (refreshAttempts === 1) {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
requestId: "mock-request",
error: {
code: "SYS_001",
message: "transient bootstrap error",
details: {},
},
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({
accessToken: "bootstrap-token",
expiresIn: 900,
})),
});
});
await mockNavigation(page, "employee");
await mockPortalHome(page, "employee");
await mockAttendanceToday(page);
await mockNotificationList(page);
await mockMe(page, ["employee"], "Employee");
await page.goto("/portal");
await expect(page).toHaveURL(/\/portal/);
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
expect(refreshAttempts).toBeGreaterThanOrEqual(2);
});
test("Portal 域管理角色也进入经理视图", async ({ page }) => {
await mockPasswordLogin(page);
await mockNavigation(page, "manager");
await mockPortalHome(page, "manager");
await mockAttendanceToday(page);
await mockNotificationList(page);
await mockMe(page, ["administration"], "Administration");
await login(page, EMPLOYEE);
@@ -309,9 +429,11 @@ test("企微 WebView 下顶栏显示入口提示", async ({ page }) => {
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
});
});
await mockPasswordLogin(page);
await mockNavigation(page, "employee");
await mockPortalHome(page, "employee");
await mockAttendanceToday(page);
await mockNotificationList(page);
await mockMe(page, ["employee"], "Employee");
await login(page, EMPLOYEE);