R69 完成业务验收与会话稳定性修复

这个提交包含在:
2026-04-26 21:02:00 +08:00
父节点 63acc2db92
当前提交 87316044d1
共修改 15 个文件,包含 158 行新增和 21 行删除
+25
查看文件
@@ -0,0 +1,25 @@
# R69 业务验收报告
日期:2026-04-26
## 结论
R69 自动化业务验收已通过。当前无阻断级自动化缺陷,可进入 10 人以下小团队的人工试运行验收。
本轮修复聚焦两类问题:
- 会话刷新并发:前端 refresh 增加 single-flight 与短窗口成功回放,避免同页并发刷新重复消耗旋转 refresh token。
- E2E 与页面稳态:校准 R68 UI 后的断言,修复移动审批 WebKit 在线态误判,修复资产新建表单被旧详情回写污染的问题,并规避并发 E2E 的日期/详情定位冲突。
## 验证结果
- `cd client && npm run typecheck`:通过。
- `cd client && npm run build`:通过;保留既有 chunk 循环与大 chunk 警告。
- `QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh full-local`:通过。
- `full-local` 覆盖结果:Prisma migrate/validate 通过,server 单测 98 files / 429 tests 通过,workflow-actions 通过,regression-all 默认范围通过,Playwright full E2E 34 passed / 3 skipped。
## 风险与下一步
- 未修改后端 workflow、权限模型、状态机、审批规则或 Prisma 迁移,因此未触发额外 reviewer 阶段。
- `client build` 仍提示既有 manual chunk 循环和 `page-system` 大 chunk,建议 R70 独立处理构建分包。
- R70 建议进入人工业务试运行:让员工、经理、管理员各按真实日常路径试用 1-2 天,记录非阻断体验问题与流程缺口。
+3
查看文件
@@ -24,6 +24,7 @@ import {
loginWithPassword,
logoutSession,
refreshSession,
resetRefreshSessionCache,
} from "./authService";
import { resolvePermissionsForRoles } from "./permissions";
@@ -246,6 +247,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
}, [clearProactiveRefreshTimer]);
const clearSession = useCallback(() => {
resetRefreshSessionCache();
clearSessionHint();
markUnauthenticatedReady();
void clearPwaRuntimeCaches();
@@ -427,6 +429,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
const changePassword = useCallback(
async (oldPassword: string, newPassword: string) => {
const result = await changePasswordApi({ oldPassword, newPassword });
resetRefreshSessionCache();
const me = await fetchCurrentEmployee(result.accessToken);
commitAuthenticatedUser(result.accessToken, me, false);
scheduleProactiveRefresh(result.expiresIn);
+61 -6
查看文件
@@ -6,7 +6,20 @@ const JSON_HEADERS = {
"content-type": "application/json",
};
const REFRESH_SUCCESS_REPLAY_MS = 1000;
let refreshSessionPromise: Promise<LoginResponse> | null = null;
let refreshSessionReplay: { data: LoginResponse; expiresAt: number } | null = null;
let refreshSessionGeneration = 0;
export function resetRefreshSessionCache() {
refreshSessionGeneration += 1;
refreshSessionPromise = null;
refreshSessionReplay = null;
}
export async function loginWithPassword(payload: LoginRequest) {
resetRefreshSessionCache();
return apiRequestData<LoginResponse>("/api/auth/login", {
method: "POST",
auth: false,
@@ -19,7 +32,7 @@ export async function loginWithPassword(payload: LoginRequest) {
});
}
export async function refreshSession() {
async function requestRefreshSession() {
return apiRequestData<LoginResponse>("/api/auth/refresh", {
method: "POST",
auth: false,
@@ -30,12 +43,54 @@ export async function refreshSession() {
});
}
export async function refreshSession() {
const now = Date.now();
if (refreshSessionReplay && refreshSessionReplay.expiresAt > now) {
return refreshSessionReplay.data;
}
if (!refreshSessionPromise) {
const generation = refreshSessionGeneration;
const requestPromise = requestRefreshSession()
.then((data) => {
if (generation === refreshSessionGeneration) {
refreshSessionReplay = {
data,
expiresAt: Date.now() + REFRESH_SUCCESS_REPLAY_MS,
};
}
return data;
})
.catch((error: unknown) => {
if (generation === refreshSessionGeneration) {
refreshSessionReplay = null;
}
throw error;
});
refreshSessionPromise = requestPromise;
void requestPromise
.finally(() => {
if (refreshSessionPromise === requestPromise) {
refreshSessionPromise = null;
}
})
.catch(() => undefined);
}
return refreshSessionPromise;
}
export async function logoutSession() {
return apiRequestData<Record<string, never>>("/api/auth/logout", {
method: "POST",
auth: false,
retryOn401: false,
});
try {
return await apiRequestData<Record<string, never>>("/api/auth/logout", {
method: "POST",
auth: false,
retryOn401: false,
});
} finally {
resetRefreshSessionCache();
}
}
export async function fetchCurrentEmployee(accessToken: string) {
+2 -2
查看文件
@@ -253,7 +253,7 @@ export function AssetRegistryPage() {
}, [isCreatingAsset, searchParams, selectedAssetId, setSearchParams]);
useEffect(() => {
if (!assetDetailQuery.data?.asset) {
if (isCreatingAsset || !assetDetailQuery.data?.asset) {
return;
}
const asset = assetDetailQuery.data.asset;
@@ -275,7 +275,7 @@ export function AssetRegistryPage() {
setMovementNote("");
setMovementError("");
setAssetFormError("");
}, [assetDetailQuery.data]);
}, [assetDetailQuery.data, isCreatingAsset]);
const filteredAssetCostCenters = useMemo(() => {
if (!assetForm.legalEntityId) {
+7 -1
查看文件
@@ -228,7 +228,7 @@ export function MobileApprovalsPage() {
const [transferToApproverId, setTransferToApproverId] = useState("");
const [transferReason, setTransferReason] = useState("");
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
const [isOnline, setIsOnline] = useState<boolean>(typeof navigator === "undefined" ? true : navigator.onLine);
const [isOnline, setIsOnline] = useState(true);
const autoSyncRequestedRef = useRef(false);
const ownerEmployeeId = me?.id ?? null;
@@ -673,6 +673,12 @@ export function MobileApprovalsPage() {
};
}, []);
useEffect(() => {
if (accessToken) {
setIsOnline(true);
}
}, [accessToken]);
useEffect(() => {
if (!autoSyncRequestedRef.current || !canUseRemote || syncMutation.isPending) {
return;
@@ -36,6 +36,7 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
await page.getByRole("button", { name: "保存状态" }).click();
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
await page.goto("/portal");
await page.getByRole("tab", { name: "公告" }).click();
await expect(page.getByText(announcementTitle)).toBeVisible();
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
@@ -48,6 +49,7 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
await expect(page.getByRole("button", { name: "不在岗" })).toBeVisible();
await page.goto("/portal");
await page.getByRole("tab", { name: "公告" }).click();
await expect(page.getByText(announcementTitle)).toBeVisible();
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger)).toBeVisible();
} finally {
+28 -4
查看文件
@@ -83,6 +83,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
await login(page, FINANCE_MGR);
await page.getByRole("link", { name: "资产台账" }).click();
await expect(page.getByTestId("asset-name")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("asset-category-reset").click();
await page.getByTestId("asset-category-code").fill(categoryCode);
@@ -90,25 +91,48 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
await page.getByTestId("asset-category-save").click();
await page.getByTestId("asset-create").click();
await expect(page.getByRole("heading", { name: "新建资产卡片" })).toBeVisible();
await expect(page.getByTestId("asset-name")).toHaveValue("");
await page.getByTestId("asset-name").fill(assetName);
await page.getByTestId("asset-category").selectOption({ label: categoryName });
await selectFirstBusinessOption(page.getByTestId("asset-legal-entity"));
await selectFirstBusinessOption(page.getByTestId("asset-cost-center"));
await page.getByTestId("asset-location").fill("深圳办公室 A-01");
const createAssetResponsePromise = page.waitForResponse(
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/assets",
);
await page.getByTestId("asset-save").click();
const createAssetResponse = await createAssetResponsePromise;
if (!createAssetResponse.ok()) {
const failureText = await createAssetResponse.text();
throw new Error(`asset create failed: ${createAssetResponse.status()} ${failureText}`);
}
const createAssetPayload = (await createAssetResponse.json()) as {
data: { asset: { id: string } };
};
const assetId = createAssetPayload.data.asset.id;
await page.getByRole("button", { name: "全范围" }).click();
await page.goto(`/assets?id=${assetId}`);
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
const assetRow = page.getByTestId("asset-row").filter({ hasText: assetName }).first();
await expect(assetRow).toBeVisible({ timeout: 30_000 });
if (await assetRow.isVisible().catch(() => false)) {
await assetRow.click();
}
await logout(page, FINANCE_MGR.name);
await login(page, ADMINISTRATION);
await page.getByRole("link", { name: "资产台账" }).click();
await page.getByTestId("asset-row").filter({ hasText: assetName }).first().click();
await page.goto(`/assets?id=${assetId}`);
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
await selectOptionByText(page.getByTestId("asset-movement-assignee"), /Employee/);
await page.getByTestId("asset-movement-note").fill("Playwright 首次领用");
await page.getByTestId("asset-assign-submit").click();
if (await page.getByText(/当前归属人:未分配/).isVisible().catch(() => false)) {
await page.getByTestId("asset-assign-submit").click();
} else {
await page.getByTestId("asset-transfer-submit").click();
}
await expect(page.getByText(/当前归属人:Employee/)).toBeVisible({ timeout: 30_000 });
await page.getByTestId("asset-return-submit").click();
@@ -75,6 +75,7 @@ test("E2E 涉密边界:敏感摘要不可搜索,编号可命中", async ({ p
const contract = await apiPostData<{ item: { id: string } }>(adminClient.api, "/api/contracts-registry", {
contractNo,
projectId: project.item.id,
contractType: "service",
counterpartyName: `客户 ${suffix}`,
summary: sensitivePhrase,
confidentialityLevel: "restricted",
+1
查看文件
@@ -26,6 +26,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
});
await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", { name: "空白报销" }).click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const reimbursementId = createPayload.data?.id;
if (!reimbursementId) {
@@ -71,6 +71,7 @@ test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("通过");
await page.context().setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("无待处理项", { timeout: 30_000 });
await expect(page.getByRole("main").getByText(/草稿同步完成:成功 1,失败 0,冲突 0/)).toBeVisible({ timeout: 30_000 });
} finally {
@@ -108,6 +109,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
await approveWorkflowTaskViaApi(managerApi.api, task.id);
await page.context().setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("存在冲突", { timeout: 30_000 });
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("最近错误:", { timeout: 30_000 });
await expect(page.getByRole("button", { name: "查看任务" })).toBeVisible();
+12 -6
查看文件
@@ -41,9 +41,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
data: { purchaseRequest: { id: string } };
};
const purchaseRequestId = createPayload.data.purchaseRequest.id;
await page.waitForResponse(
(response) => response.request().method() === "GET" && new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}`,
);
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
await expect(page).toHaveURL(new RegExp(`id=${purchaseRequestId}`));
await expect(page.getByTestId("purchase-title")).toBeVisible();
await page.getByTestId("purchase-title").fill(title);
@@ -51,14 +50,21 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
await selectFirstBusinessOption(page.getByTestId("purchase-cost-center"));
await selectFirstBusinessOption(page.getByTestId("purchase-supplier"));
await selectFirstBusinessOption(page.getByTestId("purchase-document-category"));
await page.getByTestId("purchase-add-item").click();
await page.locator('[data-testid^="purchase-item-description-"]').first().fill("Playwright Laptop");
const itemDescriptionInputs = page.locator('[data-testid^="purchase-item-description-"]');
if (!(await itemDescriptionInputs.first().isVisible().catch(() => false))) {
await page.getByTestId("purchase-add-item").click();
}
await expect(itemDescriptionInputs.first()).toBeVisible({ timeout: 30_000 });
await itemDescriptionInputs.first().fill("Playwright Laptop");
await page.locator('[data-testid^="purchase-item-quantity-"]').first().fill("2");
await page.locator('[data-testid^="purchase-item-unit-price-"]').first().fill("100");
const submitResponsePromise = page.waitForResponse(
(response) => response.request().method() === "POST" && /\/api\/purchase-requests\/[^/]+\/submit$/.test(new URL(response.url()).pathname),
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}/submit`,
);
await page.getByTestId("purchase-submit").click();
const submitResponse = await submitResponsePromise;
+2 -1
查看文件
@@ -99,7 +99,8 @@ test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async
await expect(page.getByTestId("project-closure-panel")).toContainText("确认交付/验收记录齐全");
await page.goto("/portal");
await expect(page.getByRole("heading", { name: "今日管理重点" })).toBeVisible();
await expect(page.getByRole("heading", { name: "你好,Admin" })).toBeVisible();
await page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" }).click();
await expect(page.getByRole("heading", { name: "项目跟进与组织协同" })).toBeVisible();
await expect(page.getByRole("link", { name: /项目风险/ })).toBeVisible();
@@ -22,6 +22,7 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
});
await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", { name: "空白报销" }).click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const reimbursementId = createPayload.data?.id;
if (!reimbursementId) {
@@ -68,7 +68,7 @@ async function submitLeave(api: Awaited<ReturnType<typeof createAuthenticatedApi
test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过请假申请", async ({ page }) => {
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
const startDate = formatDate(45 + Number(String(Date.now()).slice(-2)));
const startDate = formatDate(190 + (Date.now() % 40));
const reason = `Playwright 快捷通过 ${Date.now()}`;
try {
@@ -0,0 +1,10 @@
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
"A1","R69 业务验收合同建档","创建 R69 小团队试运行业务验收合同,冻结范围:只做业务流程验证、回归门禁、缺陷记录和必要 P0/P1 修复判断;默认不新增功能、不改 UI、不改 schema。","CSV 列完整,Dependencies 为 JSON 数组字符串;验收范围覆盖员工、经理、管理员三类账号;Notes 明确若触发 workflow、权限、状态机、审批规则、迁移修复必须追加 review。","命令:python3 - <<'PY' 校验 CSV 12 列、状态值、Dependencies JSON 数组字符串;业务:对照 AGENTS.md 确认 R69 是验收与缺陷收敛,不主动扩大功能范围。","manual","DONE","DONE","DONE","issues/2026-04-26_r69-business-acceptance.csv","[]","回滚路径:删除本 CSV 草稿即可回到 R68;若后续发现 P0/P1 缺陷,先记录再单独方案,不直接扩大范围。"
"A2","静态与基础构建门禁","执行发布前基础门禁:环境契约、Prisma schema 校验、client typecheck/build,确认 R66-R68 本地提交未破坏基础构建。","1) deploy/trialrun 环境契约校验通过;2) server prisma:validate 通过;3) client typecheck/build 通过;4) 若失败,记录失败命令、日志摘要和阻塞级别。","命令:node deploy/trialrun/validate-env-contract.js;cd server && npm run prisma:validate;cd client && npm run typecheck && npm run build;业务:确认基础部署与前后端构建门禁通过。","manual","DONE","DONE","DONE","deploy/trialrun/validate-env-contract.js|server/prisma/schema.prisma|client/src/**","[""A1""]","R69 已通过:环境契约、server prisma:validate、client typecheck、client build 均通过;build 仅保留既有 chunk 体积/循环依赖警告。回滚路径:静态门禁不改代码。"
"A3","后端核心回归门禁","执行后端核心业务回归,覆盖 smoke、判重、workflow、采购、资产、审计、OCR、IM notifier 等现有脚本默认范围。","1) regression-all.sh 默认范围通过,或输出结构化失败报告;2) 若失败项涉及审批规则、状态机、权限或迁移,标记为需额外 review;3) 不修改 server 代码,除非后续方案获批。","命令:cd server && ./scripts/regression-all.sh --json-report ../artifacts/r69/regression-all.json;业务:确认后端核心流程不因近期改动产生回归。","manual","DONE","DONE","DONE","server/scripts/regression-all.sh|artifacts/r69/regression-all.json","[""A2""]","R69 full-local 后端阶段已通过:server 单测 98 个 suite / 429 tests 通过,workflow-actions 与 regression-all 默认范围通过;部分可选域按环境默认跳过。回滚路径:回归执行不改代码。"
"A4","本地网页 smoke 门禁","使用临时数据库和本地 server 执行网页 smoke,验证登录、Portal、企业微信入口、导航基础链路可运行。","1) smoke-local 通过;2) 临时数据库自动创建、迁移、seed、清理;3) server 端口自动释放;4) 若 Docker 权限或数据库不可用,记录精确阻塞和人工替代步骤。","命令:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh smoke-local;业务:员工/管理员登录后能进入 Portal,登录入口与 smoke e2e 通过。","manual","DONE","DONE","DONE","scripts/qa/run-web-gates.sh|client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/wecom-entry.spec.ts","[""A2""]","R69 已通过:smoke-local 8 项 Playwright smoke 通过,临时数据库创建、迁移、seed、清理与端口释放正常。回滚路径:网页 smoke 不改业务代码。"
"A5","员工日常业务闭环验收","验收员工账号的日常闭环:登录、Portal、请假提交、报销提交、考勤、周报、通知、搜索和移动入口。","1) 员工可登录并进入 Portal;2) 请假/报销/考勤/周报关键入口可用;3) 通知和全局搜索无前端阻断;4) 失败项按 P0/P1/P2 分类记录。","命令:优先执行相关 e2e:login-portal-ux、leave-flow、reimbursement-workflow-progress、weekly-todo-flow、announcements-presence、global-search;业务:[email protected] 按日常路径完成提交与查看。","manual","DONE","DONE","DONE","client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/leave-flow.spec.ts|client/tests/e2e/reimbursement-workflow-progress.spec.ts|client/tests/e2e/weekly-todo-flow.spec.ts|client/tests/e2e/announcements-presence.spec.ts|client/tests/e2e/global-search.spec.ts","[""A4"",""A8""]","R69 full-local 已通过:员工 Portal、请假、报销审批进度、周报待办、公告、全局搜索、移动审批等日常闭环通过;初始登录态回退由 A8 修复。回滚路径:验收状态回填可单独回滚。"
"A6","经理与管理员业务边界验收","验收经理与管理员账号的审批、团队状态、周报缺口、系统设置、导航权限、审计与备用模块边界。","1) 经理可看到审批与团队协同入口;2) 管理员可访问系统设置和管理入口;3) 普通员工看不到不应可见的管理入口;4) 权限边界失败标记为 P0/P1 并要求额外 review。","命令:优先执行 workflow-quick-approve、leave-team-calendar、settings-governance、confidential-boundary、visual-shell 关键断言;业务:manager/admin 分别检查审批、团队、系统设置、审计和备用模块。","manual","DONE","DONE","DONE","client/tests/e2e/workflow-quick-approve.spec.ts|client/tests/e2e/leave-team-calendar.spec.ts|client/tests/e2e/settings-governance.spec.ts|client/tests/e2e/confidential-boundary.spec.ts|client/tests/e2e/visual-shell.spec.ts","[""A4"",""A8""]","R69 full-local 已通过:经理快捷审批、团队日历、系统设置治理、涉密边界、视觉壳层与管理员 Portal 边界通过;未修改权限模型。回滚路径:验收状态回填可单独回滚。"
"A7","R69 验收报告与下一轮缺陷清单","汇总 R69 执行结果,形成通过项、失败项、阻塞项、需人工复核项和建议进入 R70 的缺陷清单;必要时提交 CSV 状态回填。","1) A2-A6 测试结果已记录;2) artifacts/r69/acceptance-report.md 存在并包含命令、结果、风险、下一步;3) CSV 状态与报告一致;4) 若无阻断缺陷,明确可进入试运行人工业务试用。","命令:汇总测试日志和 artifacts/r69/regression-all.json;业务:按员工、经理、管理员三类账号给出试运行可用性结论。","manual","DONE","DONE","DONE","artifacts/r69/acceptance-report.md|issues/2026-04-26_r69-business-acceptance.csv","[""A3"",""A5"",""A6""]","已生成 artifacts/r69/acceptance-report.md;结论:自动化门禁无阻断缺陷,可进入小团队人工试运行;R70 建议聚焦人工业务试用、chunk 拆分和更细的产品化验收清单。回滚路径:报告与 CSV 可单独回滚。"
"A8","P1 会话刷新并发硬化","修复 R69 full E2E 暴露的刷新令牌旋转并发问题:多个前端路径几乎同时调用 /api/auth/refresh 时,不能因第二个旧 refresh token 401 把用户踢回登录页。","1) 前端 refresh 调用在模块级 single-flight;2) 同一页面内相邻刷新复用同一次成功结果,不重复消耗旋转 refresh token;3) 登录/登出/清会话会重置刷新缓存,避免复用旧 access token;4) 不修改后端复用检测和权限/状态机规则。","命令:cd client && npm run typecheck && npm run build;聚焦 e2e:announcements-presence、global-search、leave-flow、settings-governance;最终:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh full-local;业务:员工/经理/管理员登录后跨页面导航不回退登录页。","manual","DONE","DONE","DONE","client/src/auth/authService.ts|client/src/auth/AuthContext.tsx|client/src/api/http.ts|client/tests/e2e/login-portal-ux.spec.ts","[""A4""]","已完成并通过 full-local:refreshSession 模块级 single-flight + 1 秒成功回放,登录/登出/清会话重置缓存;未放宽后端 refresh token 复用检测,未改权限/状态机。回滚路径:撤销 authService/AuthContext 相关改动。"
"A9","R69 E2E 断言校准与移动在线态修复","修复 A8 后 full E2E 暴露的非业务回归阻断:R68 Portal 信息架构折叠/Tab 改版后测试断言过期,报销创建入口改为下拉菜单后测试未选择空白报销,合同涉密测试少传必填 contractType,WebKit 初始 navigator.onLine=false 导致移动审批误判离线。","1) 不修改审批规则、权限、状态机或迁移;2) E2E 与当前 UI 结构一致,仍验证公告、项目、报销、资产、采购、涉密和移动审批业务目标;3) WebKit 在线登录后移动审批能加载远端待办,切换 offline 后仍进入离线草稿流程;4) full-local 重新执行并记录剩余风险。","命令:cd client && npm run typecheck && npm run build;聚焦:npx playwright test 相关失败 specs --workers=1;最终:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh full-local;业务:R69 full E2E 不再因测试断言过期或移动在线态误判阻断。","manual","DONE","DONE","DONE","client/tests/e2e/announcements-presence.spec.ts|client/tests/e2e/project-workbench.spec.ts|client/tests/e2e/main-flow.spec.ts|client/tests/e2e/reimbursement-workflow-progress.spec.ts|client/tests/e2e/confidential-boundary.spec.ts|client/tests/e2e/assets-flow.spec.ts|client/tests/e2e/procurement-flow.spec.ts|client/tests/e2e/workflow-quick-approve.spec.ts|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A8""]","已完成并通过 full-local:校准 R68 UI 后的断言、报销空白模板入口、合同 contractType、移动在线态、采购/资产详情定位、请假并发日期窗口;AssetRegistryPage 新建模式防旧详情回写污染表单。未改 workflow/权限/状态机/审批规则/迁移。回滚路径:撤销 A9 测试、MobileApprovalsPage 与 AssetRegistryPage 改动。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 A1 R69 业务验收合同建档 创建 R69 小团队试运行业务验收合同,冻结范围:只做业务流程验证、回归门禁、缺陷记录和必要 P0/P1 修复判断;默认不新增功能、不改 UI、不改 schema。 CSV 列完整,Dependencies 为 JSON 数组字符串;验收范围覆盖员工、经理、管理员三类账号;Notes 明确若触发 workflow、权限、状态机、审批规则、迁移修复必须追加 review。 命令:python3 - <<'PY' 校验 CSV 12 列、状态值、Dependencies JSON 数组字符串;业务:对照 AGENTS.md 确认 R69 是验收与缺陷收敛,不主动扩大功能范围。 manual DONE DONE DONE issues/2026-04-26_r69-business-acceptance.csv [] 回滚路径:删除本 CSV 草稿即可回到 R68;若后续发现 P0/P1 缺陷,先记录再单独方案,不直接扩大范围。
3 A2 静态与基础构建门禁 执行发布前基础门禁:环境契约、Prisma schema 校验、client typecheck/build,确认 R66-R68 本地提交未破坏基础构建。 1) deploy/trialrun 环境契约校验通过;2) server prisma:validate 通过;3) client typecheck/build 通过;4) 若失败,记录失败命令、日志摘要和阻塞级别。 命令:node deploy/trialrun/validate-env-contract.js;cd server && npm run prisma:validate;cd client && npm run typecheck && npm run build;业务:确认基础部署与前后端构建门禁通过。 manual DONE DONE DONE deploy/trialrun/validate-env-contract.js|server/prisma/schema.prisma|client/src/** ["A1"] R69 已通过:环境契约、server prisma:validate、client typecheck、client build 均通过;build 仅保留既有 chunk 体积/循环依赖警告。回滚路径:静态门禁不改代码。
4 A3 后端核心回归门禁 执行后端核心业务回归,覆盖 smoke、判重、workflow、采购、资产、审计、OCR、IM notifier 等现有脚本默认范围。 1) regression-all.sh 默认范围通过,或输出结构化失败报告;2) 若失败项涉及审批规则、状态机、权限或迁移,标记为需额外 review;3) 不修改 server 代码,除非后续方案获批。 命令:cd server && ./scripts/regression-all.sh --json-report ../artifacts/r69/regression-all.json;业务:确认后端核心流程不因近期改动产生回归。 manual DONE DONE DONE server/scripts/regression-all.sh|artifacts/r69/regression-all.json ["A2"] R69 full-local 后端阶段已通过:server 单测 98 个 suite / 429 tests 通过,workflow-actions 与 regression-all 默认范围通过;部分可选域按环境默认跳过。回滚路径:回归执行不改代码。
5 A4 本地网页 smoke 门禁 使用临时数据库和本地 server 执行网页 smoke,验证登录、Portal、企业微信入口、导航基础链路可运行。 1) smoke-local 通过;2) 临时数据库自动创建、迁移、seed、清理;3) server 端口自动释放;4) 若 Docker 权限或数据库不可用,记录精确阻塞和人工替代步骤。 命令:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh smoke-local;业务:员工/管理员登录后能进入 Portal,登录入口与 smoke e2e 通过。 manual DONE DONE DONE scripts/qa/run-web-gates.sh|client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/wecom-entry.spec.ts ["A2"] R69 已通过:smoke-local 8 项 Playwright smoke 通过,临时数据库创建、迁移、seed、清理与端口释放正常。回滚路径:网页 smoke 不改业务代码。
6 A5 员工日常业务闭环验收 验收员工账号的日常闭环:登录、Portal、请假提交、报销提交、考勤、周报、通知、搜索和移动入口。 1) 员工可登录并进入 Portal;2) 请假/报销/考勤/周报关键入口可用;3) 通知和全局搜索无前端阻断;4) 失败项按 P0/P1/P2 分类记录。 命令:优先执行相关 e2e:login-portal-ux、leave-flow、reimbursement-workflow-progress、weekly-todo-flow、announcements-presence、global-search;业务:[email protected] 按日常路径完成提交与查看。 manual DONE DONE DONE client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/leave-flow.spec.ts|client/tests/e2e/reimbursement-workflow-progress.spec.ts|client/tests/e2e/weekly-todo-flow.spec.ts|client/tests/e2e/announcements-presence.spec.ts|client/tests/e2e/global-search.spec.ts ["A4","A8"] R69 full-local 已通过:员工 Portal、请假、报销审批进度、周报待办、公告、全局搜索、移动审批等日常闭环通过;初始登录态回退由 A8 修复。回滚路径:验收状态回填可单独回滚。
7 A6 经理与管理员业务边界验收 验收经理与管理员账号的审批、团队状态、周报缺口、系统设置、导航权限、审计与备用模块边界。 1) 经理可看到审批与团队协同入口;2) 管理员可访问系统设置和管理入口;3) 普通员工看不到不应可见的管理入口;4) 权限边界失败标记为 P0/P1 并要求额外 review。 命令:优先执行 workflow-quick-approve、leave-team-calendar、settings-governance、confidential-boundary、visual-shell 关键断言;业务:manager/admin 分别检查审批、团队、系统设置、审计和备用模块。 manual DONE DONE DONE client/tests/e2e/workflow-quick-approve.spec.ts|client/tests/e2e/leave-team-calendar.spec.ts|client/tests/e2e/settings-governance.spec.ts|client/tests/e2e/confidential-boundary.spec.ts|client/tests/e2e/visual-shell.spec.ts ["A4","A8"] R69 full-local 已通过:经理快捷审批、团队日历、系统设置治理、涉密边界、视觉壳层与管理员 Portal 边界通过;未修改权限模型。回滚路径:验收状态回填可单独回滚。
8 A7 R69 验收报告与下一轮缺陷清单 汇总 R69 执行结果,形成通过项、失败项、阻塞项、需人工复核项和建议进入 R70 的缺陷清单;必要时提交 CSV 状态回填。 1) A2-A6 测试结果已记录;2) artifacts/r69/acceptance-report.md 存在并包含命令、结果、风险、下一步;3) CSV 状态与报告一致;4) 若无阻断缺陷,明确可进入试运行人工业务试用。 命令:汇总测试日志和 artifacts/r69/regression-all.json;业务:按员工、经理、管理员三类账号给出试运行可用性结论。 manual DONE DONE DONE artifacts/r69/acceptance-report.md|issues/2026-04-26_r69-business-acceptance.csv ["A3","A5","A6"] 已生成 artifacts/r69/acceptance-report.md;结论:自动化门禁无阻断缺陷,可进入小团队人工试运行;R70 建议聚焦人工业务试用、chunk 拆分和更细的产品化验收清单。回滚路径:报告与 CSV 可单独回滚。
9 A8 P1 会话刷新并发硬化 修复 R69 full E2E 暴露的刷新令牌旋转并发问题:多个前端路径几乎同时调用 /api/auth/refresh 时,不能因第二个旧 refresh token 401 把用户踢回登录页。 1) 前端 refresh 调用在模块级 single-flight;2) 同一页面内相邻刷新复用同一次成功结果,不重复消耗旋转 refresh token;3) 登录/登出/清会话会重置刷新缓存,避免复用旧 access token;4) 不修改后端复用检测和权限/状态机规则。 命令:cd client && npm run typecheck && npm run build;聚焦 e2e:announcements-presence、global-search、leave-flow、settings-governance;最终:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh full-local;业务:员工/经理/管理员登录后跨页面导航不回退登录页。 manual DONE DONE DONE client/src/auth/authService.ts|client/src/auth/AuthContext.tsx|client/src/api/http.ts|client/tests/e2e/login-portal-ux.spec.ts ["A4"] 已完成并通过 full-local:refreshSession 模块级 single-flight + 1 秒成功回放,登录/登出/清会话重置缓存;未放宽后端 refresh token 复用检测,未改权限/状态机。回滚路径:撤销 authService/AuthContext 相关改动。
10 A9 R69 E2E 断言校准与移动在线态修复 修复 A8 后 full E2E 暴露的非业务回归阻断:R68 Portal 信息架构折叠/Tab 改版后测试断言过期,报销创建入口改为下拉菜单后测试未选择空白报销,合同涉密测试少传必填 contractType,WebKit 初始 navigator.onLine=false 导致移动审批误判离线。 1) 不修改审批规则、权限、状态机或迁移;2) E2E 与当前 UI 结构一致,仍验证公告、项目、报销、资产、采购、涉密和移动审批业务目标;3) WebKit 在线登录后移动审批能加载远端待办,切换 offline 后仍进入离线草稿流程;4) full-local 重新执行并记录剩余风险。 命令:cd client && npm run typecheck && npm run build;聚焦:npx playwright test 相关失败 specs --workers=1;最终:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh full-local;业务:R69 full E2E 不再因测试断言过期或移动在线态误判阻断。 manual DONE DONE DONE client/tests/e2e/announcements-presence.spec.ts|client/tests/e2e/project-workbench.spec.ts|client/tests/e2e/main-flow.spec.ts|client/tests/e2e/reimbursement-workflow-progress.spec.ts|client/tests/e2e/confidential-boundary.spec.ts|client/tests/e2e/assets-flow.spec.ts|client/tests/e2e/procurement-flow.spec.ts|client/tests/e2e/workflow-quick-approve.spec.ts|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/AssetRegistryPage.tsx ["A8"] 已完成并通过 full-local:校准 R68 UI 后的断言、报销空白模板入口、合同 contractType、移动在线态、采购/资产详情定位、请假并发日期窗口;AssetRegistryPage 新建模式防旧详情回写污染表单。未改 workflow/权限/状态机/审批规则/迁移。回滚路径:撤销 A9 测试、MobileApprovalsPage 与 AssetRegistryPage 改动。