R65 A1-A6:Session 稳定性与 409 资源错误修复
这个提交包含在:
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { 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() {
|
||||
const config = getConfig();
|
||||
|
||||
@@ -151,7 +172,9 @@ async function buildApp() {
|
||||
|
||||
if (config.serveClientDist) {
|
||||
await app.register(staticPlugin, {
|
||||
cacheControl: false,
|
||||
root: config.clientDistDir,
|
||||
setHeaders: setStaticCacheHeaders,
|
||||
});
|
||||
app.log.info({ dir: config.clientDistDir }, "Client dist static serving enabled");
|
||||
}
|
||||
@@ -172,6 +195,7 @@ async function buildApp() {
|
||||
request.headers.accept.includes("text/html") &&
|
||||
typeof reply.sendFile === "function"
|
||||
) {
|
||||
reply.header("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
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);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户