R65 A1-A6:Session 稳定性与 409 资源错误修复
这个提交包含在:
@@ -9,7 +9,14 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { configureHttpAuth } from "../api/http";
|
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 { changePassword as changePasswordApi } from "../api/auth";
|
||||||
import { clearPwaRuntimeCaches } from "../pwa";
|
import { clearPwaRuntimeCaches } from "../pwa";
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +83,16 @@ function clearSessionHint() {
|
|||||||
document.cookie = "has_session=; path=/; max-age=0";
|
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 {
|
function normalizeProfileCompleteness(value: unknown): ProfileCompleteness {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return DEFAULT_PROFILE_COMPLETENESS;
|
return DEFAULT_PROFILE_COMPLETENESS;
|
||||||
@@ -353,19 +370,39 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
|
|
||||||
if (!bootstrapPromiseRef.current) {
|
if (!bootstrapPromiseRef.current) {
|
||||||
bootstrapPromiseRef.current = (async () => {
|
bootstrapPromiseRef.current = (async () => {
|
||||||
try {
|
const completeBootstrap = (refreshed: LoginResponse | null) => {
|
||||||
const refreshed = await runRefresh();
|
|
||||||
if (refreshed?.expiresIn) {
|
if (refreshed?.expiresIn) {
|
||||||
scheduleProactiveRefresh(refreshed.expiresIn);
|
scheduleProactiveRefresh(refreshed.expiresIn);
|
||||||
}
|
}
|
||||||
|
|
||||||
return refreshed?.accessToken ?? null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
clearSession();
|
|
||||||
return null;
|
|
||||||
} finally {
|
} finally {
|
||||||
bootstrapPromiseRef.current = null;
|
bootstrapPromiseRef.current = null;
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-1
@@ -7,9 +7,105 @@ import { ErrorBoundary } from "./components/common/ErrorBoundary";
|
|||||||
import { Toaster } from "./components/ui/sonner";
|
import { Toaster } from "./components/ui/sonner";
|
||||||
import { queryClient } from "./query/queryClient";
|
import { queryClient } from "./query/queryClient";
|
||||||
import { AppRouter } from "./router";
|
import { AppRouter } from "./router";
|
||||||
import { registerPwaIfEnabled } from "./pwa";
|
import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa";
|
||||||
import "./index.css";
|
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();
|
registerPwaIfEnabled();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
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 OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"];
|
||||||
const CLEARABLE_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime", "workbox-precache"];
|
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() {
|
export function registerPwaIfEnabled() {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
return;
|
return;
|
||||||
@@ -14,13 +37,25 @@ export function registerPwaIfEnabled() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerSW({
|
let updateServiceWorker: ((reloadPage?: boolean) => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
updateServiceWorker = registerSW({
|
||||||
immediate: true,
|
immediate: true,
|
||||||
|
onNeedRefresh() {
|
||||||
|
void clearPwaRuntimeCaches()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
void updateServiceWorker?.(true);
|
||||||
|
});
|
||||||
|
},
|
||||||
onOfflineReady() {
|
onOfflineReady() {
|
||||||
if (canUseWindow()) {
|
if (canUseWindow()) {
|
||||||
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
|
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRegisteredSW(_swScriptUrl, registration) {
|
||||||
|
bindServiceWorkerUpdateCleanup(registration);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) {
|
||||||
return page.route("**/api/me", async (route) => {
|
return page.route("**/api/me", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -211,9 +271,11 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||||
|
await mockPasswordLogin(page);
|
||||||
await mockNavigation(page, "employee");
|
await mockNavigation(page, "employee");
|
||||||
await mockPortalHome(page, "employee");
|
await mockPortalHome(page, "employee");
|
||||||
await mockAttendanceToday(page);
|
await mockAttendanceToday(page);
|
||||||
|
await mockNotificationList(page);
|
||||||
await mockMe(page, ["employee"], "Employee");
|
await mockMe(page, ["employee"], "Employee");
|
||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
@@ -241,9 +303,11 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
|
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
|
||||||
|
await mockPasswordLogin(page);
|
||||||
await mockNavigation(page, "manager");
|
await mockNavigation(page, "manager");
|
||||||
await mockPortalHome(page, "manager");
|
await mockPortalHome(page, "manager");
|
||||||
await mockAttendanceToday(page);
|
await mockAttendanceToday(page);
|
||||||
|
await mockNotificationList(page);
|
||||||
await mockMe(page, ["admin"], "Admin");
|
await mockMe(page, ["admin"], "Admin");
|
||||||
|
|
||||||
await login(page, ADMIN);
|
await login(page, ADMIN);
|
||||||
@@ -274,25 +338,81 @@ test("Portal 新员工引导先显示欢迎弹窗,再显示快速上手清单"
|
|||||||
window.localStorage.removeItem("hyb_oa_getting_started_dismissed");
|
window.localStorage.removeItem("hyb_oa_getting_started_dismissed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await mockPasswordLogin(page);
|
||||||
await mockNavigation(page, "employee");
|
await mockNavigation(page, "employee");
|
||||||
await mockPortalHome(page, "employee");
|
await mockPortalHome(page, "employee");
|
||||||
await mockAttendanceToday(page);
|
await mockAttendanceToday(page);
|
||||||
|
await mockNotificationList(page);
|
||||||
await mockMe(page, ["employee"], "Employee");
|
await mockMe(page, ["employee"], "Employee");
|
||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).toBeVisible();
|
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.welcomeDialog)).not.toBeVisible();
|
||||||
await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).toBeVisible();
|
await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).toBeVisible();
|
||||||
await page.getByTestId(TEST_IDS.onboarding.gettingStartedDismiss).click();
|
await page.getByTestId(TEST_IDS.onboarding.gettingStartedDismiss).click();
|
||||||
await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).not.toBeVisible();
|
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 }) => {
|
test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
||||||
|
await mockPasswordLogin(page);
|
||||||
await mockNavigation(page, "manager");
|
await mockNavigation(page, "manager");
|
||||||
await mockPortalHome(page, "manager");
|
await mockPortalHome(page, "manager");
|
||||||
await mockAttendanceToday(page);
|
await mockAttendanceToday(page);
|
||||||
|
await mockNotificationList(page);
|
||||||
await mockMe(page, ["administration"], "Administration");
|
await mockMe(page, ["administration"], "Administration");
|
||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
@@ -309,9 +429,11 @@ test("企微 WebView 下顶栏显示入口提示", async ({ page }) => {
|
|||||||
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await mockPasswordLogin(page);
|
||||||
await mockNavigation(page, "employee");
|
await mockNavigation(page, "employee");
|
||||||
await mockPortalHome(page, "employee");
|
await mockPortalHome(page, "employee");
|
||||||
await mockAttendanceToday(page);
|
await mockAttendanceToday(page);
|
||||||
|
await mockNotificationList(page);
|
||||||
await mockMe(page, ["employee"], "Employee");
|
await mockMe(page, ["employee"], "Employee");
|
||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
"A1","R65 执行合同建档与范围冻结","创建并校验 R65 Session 稳定性与 409 资源错误修复执行合同 CSV,冻结范围:1) 诊断并修复静态资源 409/缓存策略问题;2) 增强 auth bootstrap refresh 容错;3) 增强 PWA/chunk 更新恢复;4) 不新增 Prisma 迁移,不新增 npm 依赖,不改登录/refresh API 协议。","issues/2026-04-16_r65-session-stability.csv 列完整、Dependencies 为 JSON 数组字符串、A2-A6 范围与当前方案一致,且 Notes 明确无迁移、无新依赖、不改 auth API 协议与回滚方式。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-16_r65-session-stability.csv。业务:检查 1) 12 列完整;2) A2-A6 依赖链合理;3) Notes 明确无 Prisma 迁移、无新依赖、不改 auth API 协议与回滚路径。","manual","DONE","DONE","DONE","issues/2026-04-16_r65-session-stability.csv","[]","回滚路径:删除本次 CSV 不影响业务。"
|
||||||
|
"A2","PWA/chunk 更新恢复与 409 诊断修复","在 client/src/pwa.ts 与 client/src/main.tsx 中补充 Service Worker 更新接管和 chunk 加载失败的一次性自恢复逻辑,以降低旧 HTML/SW 与新 chunk hash 竞态导致的 409/加载失败。","1) 保留现有 Workbox 基础配置;2) 检测到 SW 更新时会清理运行时 cache 并立即接管;3) 对 ChunkLoadError、Loading chunk、Failed to fetch dynamically imported module 触发一次性清缓存 + reload;4) sessionStorage 护栏避免无限刷新;5) 不修改 deploy/trialrun/nginx-hyb-oa.conf。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) /portal 或其他懒加载页面遇到旧 chunk 失配时仅自动刷新一次;2) 新 SW 接管后旧 precache 不继续引用旧 hash 资源;3) 不引入新依赖。","manual","DONE","DONE","DONE","client/src/pwa.ts|client/src/main.tsx|client/vite.config.ts","[""A1""]","回滚路径:移除 SW 更新接管与 chunk 恢复逻辑,恢复为现有 PWA 注册行为。无 Prisma 迁移,无新依赖,不改 API 协议。"
|
||||||
|
"A3","Auth bootstrap refresh 容错增强","在 client/src/auth/AuthContext.tsx 中增强 bootstrapByRefresh 错误分类,仅在确定 refresh token 失效时清理会话;对网络/瞬时错误增加一次延迟重试,避免误跳 /login。","1) SessionExpiredError 或 ApiError.status===401 时立即 clearSession;2) 其他异常等待 1 秒后只重试 1 次;3) 第二次失败时若离线且存在 offline snapshot 则继续走离线快照;否则再 clearSession;4) 保持 has_session hint、refreshRequestRef 去重、离线快照逻辑和后端 auth 协议不变。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 刷新 token 首次瞬时失败、第二次成功时,访问 /portal 不应被误跳 /login;2) refresh 返回 401 时应进入未登录 ready 态;3) 离线且已有快照时仍可走离线访问分支。","manual","DONE","DONE","DONE","client/src/auth/AuthContext.tsx|client/src/api/http.ts|client/src/router/guards.tsx","[""A1""]","回滚路径:移除 bootstrap refresh 重试与错误分类,恢复为单次失败即 clearSession。无 Prisma 迁移,无新依赖,不改 auth API 协议。"
|
||||||
|
"A4","服务端静态资源缓存头修复","在 server/src/app.js 的 @fastify/static 注册中增加 setHeaders,确保 HTML、SW、manifest 走 no-cache,带 hash 的 assets 与 workbox 资源走 immutable 长缓存,减少旧 HTML 指向旧 chunk。","1) /、index.html、sw.js、manifest.webmanifest、registerSW.js 返回 Cache-Control: no-cache, no-store, must-revalidate;2) /assets/* 与 workbox-*.js 返回 Cache-Control: public, max-age=31536000, immutable;3) 保留现有 SPA fallback;4) 不新增自定义 409 路由逻辑,不改 API 路由。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/app.js。业务:1) 直连 127.0.0.1:3000 请求首页时响应头为 no-cache;2) 从首页解析出的 hash JS 文件返回 200 且为 immutable;3) 非 API HTML GET 仍回到 index.html。","manual","DONE","DONE","DONE","server/src/app.js","[""A1"",""A2""]","回滚路径:移除静态资源 setHeaders,恢复 Fastify 默认静态缓存头。无 Prisma 迁移,无新依赖,不改 API 协议。"
|
||||||
|
"A5","测试与回归验证","补充服务端静态缓存头单测与前端 Portal 登录稳定性 E2E,执行 R65 相关语法、单测、构建与 E2E 验证,确认 session 与静态资源修复无回归。","1) 新增 server/tests/unit/app.static-cache.test.js 覆盖首页、sw、manifest、assets、workbox 缓存头;2) 扩展 client/tests/e2e/login-portal-ux.spec.ts 覆盖 refresh 首次失败后重试成功时 Portal 不误跳登录;3) client typecheck/build 通过;4) 服务端静态缓存头测试通过。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/app.js && npm test -- tests/unit/app.static-cache.test.js && cd ../client && npm run typecheck && npm run build && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/login-portal-ux.spec.ts。业务:1) 预置 has_session=1 且 mock refresh 首次失败、第二次成功后打开 /portal,仍留在 /portal;2) 首页与 hash 资源缓存头符合预期。","manual","DONE","DONE","DONE","server/src/app.js|server/tests/unit/app.static-cache.test.js|client/src/pwa.ts|client/src/main.tsx|client/src/auth/AuthContext.tsx|client/tests/e2e/login-portal-ux.spec.ts","[""A2"",""A3"",""A4""]","回滚路径:若测试或构建失败,暂停提交并回退本轮 session/cache 修复代码。无 Prisma 迁移,无新依赖。"
|
||||||
|
"A6","部署、状态回填与提交收口","在所有验证通过后部署 trialrun,检查 /ready、首页与静态资源响应头、Portal 刷新后的登录态稳定性,并回填 CSV 状态与提交代码。","1) bash deploy/trialrun/deploy.sh 执行成功;2) /ready 可用;3) 首页与 sw.js 响应头为 no-cache;4) 从首页解析出的 /assets/*.js 返回 200 且缓存头为 immutable;5) issues/2026-04-16_r65-session-stability.csv 状态按实测结果回填;6) git commit 成功,提交信息为 R65 A1-A6:Session 稳定性与 409 资源错误修复。","命令:1) python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-16_r65-session-stability.csv;2) export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/app.js && npm test -- tests/unit/app.static-cache.test.js && cd ../client && npm run typecheck && npm run build && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/login-portal-ux.spec.ts;3) bash deploy/trialrun/deploy.sh;4) ssh [email protected] 'curl -fsS http://127.0.0.1:3000/ready';5) 远端检查 /、/sw.js、/assets/*.js 响应头与状态码。业务:部署后硬刷新 /portal,不应因 bootstrap 瞬时失败误回 /login。","manual","DONE","DONE","DONE","issues/2026-04-16_r65-session-stability.csv|server/src/app.js|server/tests/unit/app.static-cache.test.js|client/src/pwa.ts|client/src/main.tsx|client/src/auth/AuthContext.tsx|client/tests/e2e/login-portal-ux.spec.ts|deploy/trialrun/deploy.sh","[""A5""]","回滚路径:若部署后头部或登录态验证失败,则不保留最终状态并回退本轮 session/cache 修复。commit message:R65 A1-A6:Session 稳定性与 409 资源错误修复。无 Prisma 迁移,无新依赖,不改 auth API 协议。"
|
||||||
|
@@ -78,6 +78,27 @@ const { initMetrics } = require("./observability");
|
|||||||
const { AppError } = require("./utils/AppError");
|
const { AppError } = require("./utils/AppError");
|
||||||
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi");
|
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi");
|
||||||
|
|
||||||
|
const NO_CACHE_STATIC_FILES = new Set([
|
||||||
|
"index.html",
|
||||||
|
"sw.js",
|
||||||
|
"manifest.webmanifest",
|
||||||
|
"registerSW.js",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function setStaticCacheHeaders(res, filePath) {
|
||||||
|
const normalizedPath = String(filePath).replace(/\\/g, "/");
|
||||||
|
const fileName = normalizedPath.split("/").pop() ?? "";
|
||||||
|
|
||||||
|
if (NO_CACHE_STATIC_FILES.has(fileName)) {
|
||||||
|
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPath.includes("/assets/") || /\/workbox-[^/]+\.js$/i.test(normalizedPath)) {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function buildApp() {
|
async function buildApp() {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
@@ -151,7 +172,9 @@ async function buildApp() {
|
|||||||
|
|
||||||
if (config.serveClientDist) {
|
if (config.serveClientDist) {
|
||||||
await app.register(staticPlugin, {
|
await app.register(staticPlugin, {
|
||||||
|
cacheControl: false,
|
||||||
root: config.clientDistDir,
|
root: config.clientDistDir,
|
||||||
|
setHeaders: setStaticCacheHeaders,
|
||||||
});
|
});
|
||||||
app.log.info({ dir: config.clientDistDir }, "Client dist static serving enabled");
|
app.log.info({ dir: config.clientDistDir }, "Client dist static serving enabled");
|
||||||
}
|
}
|
||||||
@@ -172,6 +195,7 @@ async function buildApp() {
|
|||||||
request.headers.accept.includes("text/html") &&
|
request.headers.accept.includes("text/html") &&
|
||||||
typeof reply.sendFile === "function"
|
typeof reply.sendFile === "function"
|
||||||
) {
|
) {
|
||||||
|
reply.header("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
return reply.type("text/html").sendFile("index.html");
|
return reply.type("text/html").sendFile("index.html");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const createPrismaClient = vi.fn(() => ({
|
||||||
|
prisma: {
|
||||||
|
$connect: vi.fn().mockResolvedValue(undefined),
|
||||||
|
$disconnect: vi.fn().mockResolvedValue(undefined),
|
||||||
|
employee: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
end: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const startJobs = vi.fn(async () => ({ driver: "disabled" }));
|
||||||
|
|
||||||
|
vi.mock("../../src/prismaClient", () => ({
|
||||||
|
createPrismaClient,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/jobs/jobSystem", () => ({
|
||||||
|
startJobs,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { buildApp } = require("../../src/app");
|
||||||
|
|
||||||
|
async function createClientDistFixture() {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-static-cache-"));
|
||||||
|
await fs.mkdir(path.join(tempDir, "assets"), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(tempDir, "index.html"), "<!doctype html><html><body><div id=\"root\"></div></body></html>");
|
||||||
|
await fs.writeFile(path.join(tempDir, "sw.js"), "self.addEventListener('install', () => undefined);");
|
||||||
|
await fs.writeFile(path.join(tempDir, "manifest.webmanifest"), JSON.stringify({ name: "hyb-oa" }));
|
||||||
|
await fs.writeFile(path.join(tempDir, "registerSW.js"), "export const register = () => undefined;");
|
||||||
|
await fs.writeFile(path.join(tempDir, "workbox-abc123.js"), "self.__WB_MANIFEST = [];");
|
||||||
|
await fs.writeFile(path.join(tempDir, "assets/index-abc123.js"), "console.log('asset');");
|
||||||
|
return tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildStaticApp(clientDistDir) {
|
||||||
|
vi.stubEnv("NODE_ENV", "test");
|
||||||
|
vi.stubEnv("JWT_SECRET", "test-secret");
|
||||||
|
vi.stubEnv("SERVE_CLIENT_DIST", "true");
|
||||||
|
vi.stubEnv("CLIENT_DIST_DIR", clientDistDir);
|
||||||
|
vi.stubEnv("STORAGE_DIR", path.join(clientDistDir, "..", "storage"));
|
||||||
|
vi.stubEnv("JOBS_ENABLED", "false");
|
||||||
|
|
||||||
|
return buildApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("app static cache headers", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves shell files with no-cache and fingerprinted assets with immutable cache", async () => {
|
||||||
|
const clientDistDir = await createClientDistFixture();
|
||||||
|
const app = await buildStaticApp(clientDistDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const indexResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/",
|
||||||
|
headers: { accept: "text/html" },
|
||||||
|
});
|
||||||
|
const swResponse = await app.inject({ method: "GET", url: "/sw.js" });
|
||||||
|
const manifestResponse = await app.inject({ method: "GET", url: "/manifest.webmanifest" });
|
||||||
|
const registerResponse = await app.inject({ method: "GET", url: "/registerSW.js" });
|
||||||
|
const assetResponse = await app.inject({ method: "GET", url: "/assets/index-abc123.js" });
|
||||||
|
const workboxResponse = await app.inject({ method: "GET", url: "/workbox-abc123.js" });
|
||||||
|
|
||||||
|
expect(indexResponse.statusCode).toBe(200);
|
||||||
|
expect(swResponse.statusCode).toBe(200);
|
||||||
|
expect(manifestResponse.statusCode).toBe(200);
|
||||||
|
expect(registerResponse.statusCode).toBe(200);
|
||||||
|
expect(assetResponse.statusCode).toBe(200);
|
||||||
|
expect(workboxResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
expect(indexResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate");
|
||||||
|
expect(swResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate");
|
||||||
|
expect(manifestResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate");
|
||||||
|
expect(registerResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate");
|
||||||
|
expect(assetResponse.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
|
||||||
|
expect(workboxResponse.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
await fs.rm(clientDistDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户