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 行删除
+41 -4
查看文件
@@ -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 {
};
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;
}
} 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);
@@ -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 协议。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 不影响业务。
3 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 协议。
4 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 协议。
5 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 协议。
6 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 迁移,无新依赖。
7 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 协议。
+24
查看文件
@@ -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);
});