fix: 隔离门户待办身份并取消旧请求(09-11-reimbursement-payment-handoffs)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-11 20:21:46 +08:00
父节点 9e41f107fa
当前提交 5d8cdf12b4
共修改 10 个文件,包含 466 行新增和 26 行删除
@@ -23,6 +23,16 @@ and verification contracts. Do not copy broad aesthetic guidance from
- Reimbursement desktop payment links use content width (`justify-self-start`) and the existing 44px button height, not grid stretching across the aside. The creation label is `创建批次`; it navigates to the existing pending view and never creates a batch itself. Keep mobile touch sizing and the generic action-panel layout unchanged. Verify actual desktop bounds and that navigation sends no batch-creation POST. - Reimbursement desktop payment links use content width (`justify-self-start`) and the existing 44px button height, not grid stretching across the aside. The creation label is `创建批次`; it navigates to the existing pending view and never creates a batch itself. Keep mobile touch sizing and the generic action-panel layout unchanged. Verify actual desktop bounds and that navigation sends no batch-creation POST.
- When `PaymentBatchesPage` retains its `lg:hidden` mobile action bar, its desktop action card must keep `hidden lg:block` as well as `lg:static`; changing stickiness must not remove responsive visibility. The adjacent progress stays visible. Cover the narrow PC route in addition to the dedicated mobile route, including receipt-upload action switching. - When `PaymentBatchesPage` retains its `lg:hidden` mobile action bar, its desktop action card must keep `hidden lg:block` as well as `lg:static`; changing stickiness must not remove responsive visibility. The adjacent progress stays visible. Cover the narrow PC route in addition to the dedicated mobile route, including receipt-upload action switching.
## Convention: Portal Query Eligibility Is Not Finance Display Eligibility
- `MobilePortalPage` loads authenticated `GET /portal/home` independently of `canUseFinance`. That flag gates the finance card, not `businessHandoffs`; the server still filters each item by role, permissions, participation, department and maker-checker.
- Test employee and CEO separately on PC and mobile, starting in a fresh browser context directly at the destination. Assert the mobile request actually occurs, the authorized link appears, processing removes it after reread, and failures show retry rather than an empty list.
- A finance mobile mock plus an employee desktop test is not a role-by-device matrix. Real isolated authentication/HTTP/browser checks must exercise employee returned work and CEO independent review without granting either a finance role.
- Portal response caches are employee-scoped: PC uses `["portal-home", me?.id]`, mobile uses `["mobile", "portal", "home", me?.id]`, and both queries require an existing identity. Keep these prefixes so existing invalidation callers still match; do not use previous-key placeholder data. `staleTime: 0` causes a refetch but does not isolate identities, and clearing PWA Cache Storage does not clear the singleton React Query cache.
- Identity keys also require cancellation: both portal query functions consume their `signal` and pass it through `fetchPortalHome(signal?: AbortSignal)` to `apiRequestData`. Otherwise an old request's late 401 can refresh under the new session, return the new employee's payload and cache it under the old employee's key. Validate actual transport cancellation plus A→B→A with the final A response held, not only A→B or successful late responses. Same-identity 401 refresh must still succeed; do not disable authentication refresh globally to make the test pass.
- Validate account changes within one browser context and document, using the UI logout/login flow rather than clearing caches, reloading or creating a new context. After the second identity is visible, old handoffs must remain absent while its response is held, after an error, through retry and after the old identity's delayed request completes. Assert that a request was actually held so a fast empty result cannot hide stale rendering; cover PC and mobile. Synthetic visibility events must reach the window listener (`bubbles: true` when dispatched on document). Real-auth fixtures must resolve profile fields from the authenticated ID; `request.employee` is not the full `/me` profile.
- A scoped portal fix does not prove every application query or same-account permission change has correct cache lifecycle. Keep unrelated authentication/cache work separate, and preserve the distinction between synthetic API browser checks, real isolated authentication/HTTP and production validation.
## Scenario: Actionable Validation And Selectable Payment Channels ## Scenario: Actionable Validation And Selectable Payment Channels
### 1. Scope / Trigger ### 1. Scope / Trigger
@@ -1,9 +1,24 @@
# 最小待办交接投影 # 最小待办交接投影
## 2026-09-11 复核接续:取消旧身份门户读取
原批准的双端身份隔离仍未完整成立:迟到 401 会按当前会话刷新并重放旧请求,乙结果可进入甲 key;双端甲→乙→甲已在冻结树 dcde0d44 的合成浏览器复现。此次沿原目标修复,不改全局认证、角色或生产。只为 fetchPortalHome 增加可选 AbortSignal,两个 queryFn 消费并传递它,使旧页面卸载时 Query 层取消、传输层终止,不让旧结果缓存。
新增双端迟到 401 的真实请求取消断言及往返登录验证,并检查同身份正常 401 刷新仍可用。不把“请求成功返回”当作全部晚到场景。原 postpush-identity 包与 NO_GO 回执保留;新构建、专项/联合及真实隔离结果另存 postpush-cancellation,独立复核仍单列。
## 2026-09-11 复核后追加:门户查询按身份隔离(已批准本地实施)
- 修复前 PC ["portal-home"] 和手机 ["mobile", "portal", "home"] 跨账号共享 QueryClient 数据;staleTime: 0 只触发后台刷新,不禁止先展示已有缓存。双端合成浏览器均已在未修构建上复现乙登录后等待网络期间显示甲待办,失败证据保留在 postpush-identity/red.log。
- 在这两个现有 queryKey 后追加已确认员工 id,并以身份存在性控制查询;不以 canUseFinance 控制整个请求。保持原前缀,使既有 invalidateQueries 前缀调用仍命中;不使用保留上一 key 数据的 placeholderData/keepPreviousData。
- 不修改全局认证或通过给角色加权限解决;按身份分区使旧身份请求晚返回也不能覆盖当前门户。既有认证/PWA 清理不等于 React Query 清理,其他模块的全局缓存生命周期风险不能由本修复推断已全部解决。
- 验收须在同一页面文档切换两个合成员工:新身份已生效但门户响应未返回、响应失败及旧请求晚到时均零旧身份待办;正确响应只显示新身份事项。覆盖 PC/手机、现有角色与失效刷新。不用真实员工手测或强刷“修复”缓存。
服务端服务 `loadReimbursementPaymentHandoffs` 从业务记录只读构造 `{ total, items }`,挂在现有 portal home DTO。项目为小团队,四类活动对象按稳定 key 去重,total 从返回 items 计算;不新建队列数据库或通用任务系统。资料未齐不是忽略该报销的理由,点击进入原业务页由权威 readiness/写事务决定能否组批。 服务端服务 `loadReimbursementPaymentHandoffs` 从业务记录只读构造 `{ total, items }`,挂在现有 portal home DTO。项目为小团队,四类活动对象按稳定 key 去重,total 从返回 items 计算;不新建队列数据库或通用任务系统。资料未齐不是忽略该报销的理由,点击进入原业务页由权威 readiness/写事务决定能否组批。
只选择必要字段。退回检查最新流程最后的 return/reject 事实,禁止把历史曾退回但当前主动撤回的草稿重新标成待修改。付款查询限 reimbursement,exclude deleted/paid,排除非取消批次占用;部门 gate 在本次加载内缓存,不解密银行资料。批次复核使用原 manager/ceo+review 资格及非制单人条件;付款处理仅 manage。 只选择必要字段。退回检查最新流程最后的 return/reject 事实,禁止把历史曾退回但当前主动撤回的草稿重新标成待修改。付款查询限 reimbursement,exclude deleted/paid,排除非取消批次占用;部门 gate 在本次加载内缓存,不解密银行资料。批次复核使用原 manager/ceo+review 资格及非制单人条件;付款处理仅 manage。
门户行动行复用现有组件,旧 finance pending/failed 聚合行只作为旧服务端 DTO 兼容,收到新投影时不重复列入行动计数;独立风险提醒不冒充付款复核。移动端复用同一 items,报销使用 mobile 深链,付款沿既有移动边界。只读查询错误使用页面已有错误反馈,不回落成零待办。 门户行动行复用现有组件,旧 finance pending/failed 聚合行只作为旧服务端 DTO 兼容,收到新投影时不重复列入行动计数;独立风险提醒不冒充付款复核。移动端复用同一 items,报销使用 mobile 深链,付款沿既有移动边界。只读查询错误使用页面已有错误反馈,不回落成零待办。
09-11 提交后复核发现:手机 portalHomeQuery 仍以 canUseFinance 启用,旧财务聚合的展示资格意外限制了新增业务待办的数据加载。修复仅移除整个已认证门户查询上的财务开关,保持财务卡片的 canUseFinance 判断和服务端角色/归属/部门/非本人制单过滤;不通过给员工增加财务权限绕过缺陷。补 employee 与 ceo 独立浏览器上下文的手机直接进入测试,不能只测 finance 手机或 employee PC。
无 schema/迁移/seed/业务写入/消息发送;已有通知机制不变,通知丢失不丢业务事实。每次渲染不是批准执行,写接口仍独立授权。代码回退恢复旧门户,不改业务记录。独立 reviewer 受 inline 限制时明确 pending。 无 schema/迁移/seed/业务写入/消息发送;已有通知机制不变,通知丢失不丢业务事实。每次渲染不是批准执行,写接口仍独立授权。代码回退恢复旧门户,不改业务记录。独立 reviewer 受 inline 限制时明确 pending。
@@ -1,5 +1,80 @@
# 执行清单 # 执行清单
## 2026-09-11 只读复核后取消旧请求(原范围接续,未提交/部署)
冻结树 dcde0d44 的双端合成浏览器复核发现:甲旧请求在乙登录后返回 401,会用乙 token 重放,并把乙结果写到甲 queryKey;甲再次登录等待新响应时显示乙待办。原 6/62/真实隔离通过记录不改写,它们未覆盖该分支。只读回执见 postpush-identity/readonly-review.md。本次沿原批准的门户隔离范围返回实施,不改变全局认证或业务权限,不新建任务。
- [x] 补双端迟到 401、真实传输取消、甲→乙→甲回归,先在现有未取消构建上跑红;补同身份刷新正例。
- [x] 两页 queryFn 消费 signal,经现有门户 API 传给 HTTP 请求;不改全局认证、业务写入或其他查询。
- [x] 新构建、专项、联合页面与真实隔离验证;保留失败证据,不重复累加数量。
- [x] 主会话检查、更新稳定合同与两任务状态;新修订树及补丁重放以 postpush-cancellation/manifest.json 为准,独立复核、提交推送和部署继续分列。
实际结果:未取消构建双端红测均失败;取消修正后专项 10/10,三个既有文件同轮 66/66、退出 0、零重试(包含这 10 项)。新 typecheck/build/PWA 与真实隔离数据库/认证/HTTP/双端完整业务链通过,临时库和合成附件已清理。14 个验证源输入、115 个构建文件在冻结前核对未变;135 单测仅按未变输入复用。迟到 401 专项为合成 auth/API,不冒充真实过期会话实测;真实业务链的普通登录与切账号另有证据。
本轮复核/修复只触及旧门户请求未取消这一根因,主会话自检不抵扣独立 review。原 dcde0d44 包与 NO_GO 回执保留;不将同会话修补写为独立通过,不改全局认证或其他模块缓存。下一步将 postpush-cancellation 新输入交审,再按明确授权提交推送、冻结新 SHA、执行时预检及部署/公网自动验收。未提交、推送、连接生产或部署。
## 2026-09-11 身份隔离修复接续(用户已确认,本地实施与验证)
官方 current 在实际 integration 副本切至本 task,联合协调仍由 09-04 记录;复用原任务,不新建修复 task。批准范围是 PC/手机门户身份缓存隔离与自动验证,不含提交、推送、部署、生产资料或全局认证改造。原 readonly-review/P1 及冻结输入保持原样;新证据另存 postpush-identity。
- [x] 在现有 visual-portal spec 补同文档账号切换矩阵,先用未修正构建复现 PC/手机慢响应串显,再修两个门户 queryKey/无身份禁用门。
- [x] 同一 BrowserContext/文档验证双端慢响应、失败重试、旧请求晚返回;不清缓存、不强刷、不用新上下文替代切账号。保留已有前缀刷新及角色权限。
- [x] 新构建、定向页面、联合三文件及受影响真实隔离链路验证;本地通过与生产/独立 review 分列,未变服务端单测按输入核对复用。
- [x] 主会话检查本轮差异、补稳定 spec 和两项原任务状态;另存修订包,不覆盖旧复现或把阶段结束当上线。
本地结果:未修构建 PC/手机 slow 两项均复现串显;修后身份隔离专项 6/6,联合三文件同轮 62/62、零重试,包含该 6 项,不重复累加。新 typecheck/build/PWA 通过;真实隔离 PostgreSQL/密码登录/JWT/HTTP/双端业务链 PASS_REAL_DB_HTTP,新增同文档员工→CEO 登录、身份回读、真实门户响应暂缓和零旧待办断言通过,临时库/合成附件已清理。135/135 单测按服务端、共享前端 helper 和依赖清单未变核对复用,不称重跑。证据见联合目录 postpush-identity;具体冻结树、摘要和检查结果以该目录 manifest.json 为准。
复盘:固定 queryKey 隐含“浏览器只使用一个账号”,原独立上下文和冷进入测试漏掉会话生命周期。原始红测有效保留;修后首次专项 4/6 的两项失败是测试事件未冒泡到 window,首次真实链失败是 /me 夹具从精简 request.employee 读取了不存在的工号字段,均在身份/刷新前置阶段失败。修正事件和按认证 ID 查隔离库资料后重跑通过,没有放宽断言或更改认证实现。原失败日志仍在,不把夹具失败说成新增生产缺陷。
本轮 P1 在已测门户范围本地闭合,独立只读复核仍 PENDING_INLINE。未提交、推送、连接生产或部署;其他查询缓存、同账号权限变更、完整发布门禁、公网角色验收和正式备份应用级恢复不由本轮 PASS 推导。下一步交审新修订包,经明确提交推送授权冻结新 SHA,再更新执行时预检并另批部署/公网自动验收;员工不代测。
## 2026-09-11 只读复核 P1:同浏览器切账号待办串显(历史:当时方案待确认)
主会话检查冻结树 68f44e1531719c4d83308cc80494181f0d390803,使用本地合成 API/真实浏览器复现:甲通过界面退出、乙登录,同一文档且账号控件已显示乙,乙的门户请求尚未返回时仍显示甲的待办。正确空响应返回后消失;没有访问生产或验证服务端越权。PC 已实测,手机仅发现同类固定 queryKey,尚未复现。不重写此前 56/56、135/135 及真实隔离链路通过记录。
本轮只读检查与诊断未改运行时,旧冻结输入保留,当前候选不放行。首次诊断 /me fixture 漏 requestId,登录未通过;修正 fixture 后两次观察一致,后一次额外断言账号控件已切乙。细节见联合目录 postpush-recheck/readonly-review.md;不是独立 reviewer 结论。
- [ ] 确认后将 PC/手机门户缓存键按当前员工身份区分;无已确认身份不查询,不沿用上一身份数据或用强刷掩盖问题。保持现有前缀失效刷新、冷进入和财务卡权限。
- [ ] 在同一 BrowserContext、同一页面文档通过真实页面退出/登录路径覆盖双端,不调用清缓存/新建上下文/强刷;分别验证慢响应、失败和旧身份请求晚到均不出现他人待办。
- [ ] 保留已有角色正反例和状态消失回归;改后构建并对受影响真实隔离链路回归,再冻结新输入。认证 API、角色、生产数据和付款写入不在此修复范围;其他模块全局认证缓存另行归因,不宣称两页修复解决全站缓存。
## 2026-09-11 联合附件回归接续(最新本地验证,未提交/部署)
复用 09-04 原任务核实剩余高敏附件失败:旧材料 CSS/按钮名定位未随共享组件同步,附件在失败快照中实际存在。仅适配该测试并补单字理由/取消关闭断言,不改运行时、角色或删除规则。专项 1/1;原三份页面文件同轮 56/56、退出 0,包含本任务普通员工/CEO 双端待办冷进入、处理后消失和手机错误重试。下文“55 项通过/1 项未过”保留为上次冻结记录,不再是当前缺口。
运行时和 QA 输入与上一冻结树一致,复用 135/135 单测、构建/PWA 和真实隔离 HTTP/双端业务结果,不称本轮重跑。新复核材料见联合目录 postpush-recheck,旧 postpush 输入/失败日志不覆盖;附件专项为 mock 请求验证,不冒充生产或持久化删除。独立复核、完整门禁、明确提交推送修正授权、新候选执行时预检及部署/公网合成验收仍未完成,员工不代测。
## 2026-09-11 提交后复核与首批上线准备(本轮已批准,未部署)
用户批准按综合建议规划并实施。复用本任务修待办缺陷,09-04 维护联合交审与发布验收清单;不新增细碎 task,不继承提交/推送/部署/生产写入授权。执行副本为既有 integration,官方 current 已切换并回读为本 task。主会话 inline 实施/检查,不派 implement/check 子代理,独立 review 继续单列。
已推送 fedcaed1 的只读复核发现手机门户整体查询仍受 canUseFinance 限制:API 能返回普通员工退回事项,不代表该角色手机真正发起了查询;旧浏览器覆盖只有 finance 手机、employee PC。此前 135/39 通过记录保留,但不能证明这两个角色端组合通过,也不继续把 fedcaed1 标成无已知阻断的发布候选。
- [x] 先追加手机普通员工与单独 CEO 的冷进入回归,在旧代码上复现;保留失败证据。
- [x] 移除手机整个门户查询的财务展示门,财务卡片/服务器权限不变;补处理后消失和失败重试。
- [x] 运行定向浏览器、typecheck/build、有关真实隔离链路和治理检查;失败逐项归因,不让真实员工代测。
- [x] 核对旧候选 32 文件、两笔提交树与冻结证据;另存提交后交审封面、此次修正补丁及摘要,不改写旧 manifest/review。
- [x] 在 09-04 原计划写清首批角色剩余就绪项、发布前重查、受控公网验收与停止条件;旧附件保留标记不再追查。
- [ ] 新修正经明确提交推送授权后冻结新 SHA;独立复核、发布审批与公网验收仍待完成,不把本地修复当上线。
并行技术债继续沿既有 09-06 门禁、09-05 错误解释及 08-23 恢复任务排期。10-05 的 673 条到期记录不统一续期,主线不顺手改 F1 付款读取或其他模块。
复盘:根因是新增业务 DTO 后仍沿用旧财务卡片的查询开关,属于变更传递遗漏与角色×端覆盖缺口。原 API 回归和财务手机测试没有验证普通员工/CEO 的手机查询是否发出。针对旧代码的两个手机冷进入用例均失败(待办链接为 0),修正后 employee/ceo × PC/手机冷进入/处理后消失,加手机错误重试共 6 项通过。服务端权限、角色和持久化模型未改;后续真实隔离测试额外断言手机真正发起 portal 请求,而不是仅验证接口返回。经验已补既有前端规约,不新增经验文档。
最终本地回执:证据集中于联合目录的 postpush/。当前全部运行时修正仅 3 个前端页面(本任务手机待办、09-04 报销按钮/付款窄屏),服务端未改。unit-complete.log 135/135、build-integrated.log typecheck/build/PWA、real-db-browser-final.log PASS_REAL_DB_HTTP 且两类手机待办各自真实认证冷进入 1→0 通过;临时库/合成附件已清理。三文件扩展 ui-final.log 原始 54/56,手机断言按响应式合同纠正后 responsive-final.log 3/3,去重为 55 项已通过、1 条未改动的历史附件选择器用例仍失败;没有把扩展检查写成全绿。独立 review、提交推送修正、部署/公网验收均待完成。原 39 项与本轮回归不重复累加,腾讯实时 OCR 仍未测试。
最新前置补记:用户另批后,当前生产 fe025e65 镜像已独立保存并完成配置/14 层/归档摘要及新 SSH 回读;财务/yj 的所属主体仅两行条件修正并独立连接回读通过,未改角色/经理/银行。共用 09-04/09-08 最新回执和联合副本 artifacts/qa-flow/20260911-authorized-preparation/;下文制品/两人主体缺失是此前阶段状态。候选 fedcaed1 未变,未部署;独立复核、ARM 管理员渠道和配对恢复仍未完成。
## 2026-09-11 提交推送回执(最新,未部署)
用户已批准执行推荐下一步。本任务提交 fedcaed1a13cc3b32ee47e1d72c2b5ffa5512c57(15 文件),前一笔详情修复 f9aee4c93272204d18408217358d95334cedcd5e(19 文件);两笔最终树 476364c76cf476e6af14f0f263599f4d5eced566 与原冻结包一致,origin/main 回读 fedcaed1,待推送 0。共享 QA 按补丁拆分,没有覆盖独立副本或混入根 checkout 改动。
135/135 单测、39/39 页面和真实隔离流程的输入未变,复用原结果,不声称本轮重跑。独立 reviewer 受 inline 限制仍待完成,没有继承旧候选审查或批准部署。生产只读预检确认仍 fe025e65、68 条迁移无 pending、41.09 GiB 余量、今日同版本备份完整性通过;当前镜像独立制品未找到,ARM 归档管理员凭据待补,配对恢复未做。
四名确认账号的门户/付款部门 gate 均开启;财务/yj 经理关系正确、本人所属主体仍缺,生产字段未回填。就绪与恢复详情复用 09-04/09-08/08-23 最新回执,不把资料存在性当银行解密或真实流程通过。联合副本 artifacts/qa-flow/20260911-combined/ 保存 commit-plan、commit-receipt、push-receipt;artifacts/qa-flow/20260911-readonly/ 保存新预检。历史 manifest/补丁未重写。
本节覆盖下文“未提交/未推送”的历史状态;回执为候选之外的未提交文档补记,任务不归档。下一步独立复核及另批当前镜像保存/配对恢复和就绪缺项;部署及公网合成验收仍另批,不让真人代测。
已获 2026-09-11 综合规划实施批准;独立工作树基线 fe025e65。复用已读 backend/frontend/权限规约;未授权生产操作、提交、推送或归档。 已获 2026-09-11 综合规划实施批准;独立工作树基线 fe025e65。复用已读 backend/frontend/权限规约;未授权生产操作、提交、推送或归档。
- [x] 只读事实加载与四类投影,最小 DTO 和正反例。 - [x] 只读事实加载与四类投影,最小 DTO 和正反例。
@@ -7,7 +82,7 @@
- [x] Vitest 服务/路由、客户端 typecheck/build、合成 Playwright、真实隔离业务状态演进验证。 - [x] Vitest 服务/路由、客户端 typecheck/build、合成 Playwright、真实隔离业务状态演进验证。
- [x] 主会话只读检查和规约更新;与详情修复按补丁形成联合工作树,不整文件覆盖。 - [x] 主会话只读检查和规约更新;与详情修复按补丁形成联合工作树,不整文件覆盖。
- [ ] 独立只读 review,inline 主会话自检不抵扣。 - [ ] 独立只读 review,inline 主会话自检不抵扣。
- [ ] 明确授权后分别提交推送、冻结新候选。 - [x] 明确授权后分别提交推送,候选 fedcaed1 已冻结并远端回读。
- [ ] 最新生产/备份只读预检、就绪/恢复、获批发布及公网受控验收,分别登记,不由本地 PASS 推导。 - [ ] 最新生产/备份只读预检、就绪/恢复、获批发布及公网受控验收,分别登记,不由本地 PASS 推导。
## 2026-09-11 实施与联合验收回执 ## 2026-09-11 实施与联合验收回执
@@ -11,6 +11,8 @@
- 财务看到未付款且未被有效批次占用的已审批报销;缺资料仍是待处理,不把待组批投影说成已通过创建门禁。部门功能开关与真实批次入口权限保持一致。 - 财务看到未付款且未被有效批次占用的已审批报销;缺资料仍是待处理,不把待组批投影说成已通过创建门禁。部门功能开关与真实批次入口权限保持一致。
- 经理/CEO 的付款复核待办排除本人制单;财务看到 reviewed/failed 批次的付款/回单登记待办,不把 draft 的复核动作交给制单人。 - 经理/CEO 的付款复核待办排除本人制单;财务看到 reviewed/failed 批次的付款/回单登记待办,不把 draft 的复核动作交给制单人。
- 门户 PC/手机消费同一服务端投影;每个对象/阶段稳定唯一,数量与列表一致,处理后重新查询即消失,不依赖通知送达或复制出持久化待办。 - 门户 PC/手机消费同一服务端投影;每个对象/阶段稳定唯一,数量与列表一致,处理后重新查询即消失,不依赖通知送达或复制出持久化待办。
- 手机首次进入也必须为非财务员工和独立复核人加载本人获授权的业务待办;不能沿用旧财务汇总的角色开关限制整个门户查询,也不能依赖先访问 PC 或已有缓存。财务卡片和写接口继续执行原权限边界。
- 用户已确认本地修复门户身份隔离:同一浏览器退出并登录另一账号时,PC/手机不能显示上一账号的待办;当前响应慢、失败或旧请求晚返回也不能跨身份复用。保留正常前缀失效刷新,不扩角色或修改全局认证。
- 不输出金额、事由、姓名、银行卡或原始错误。只展示业务编号、阶段、责任职责和有权限的目标入口。手机不新增批量付款能力。 - 不输出金额、事由、姓名、银行卡或原始错误。只展示业务编号、阶段、责任职责和有权限的目标入口。手机不新增批量付款能力。
## Scope ## Scope
@@ -31,8 +33,12 @@ Vitest 四类服务与 portal route;真实隔离 PostgreSQL/认证/HTTP 退回
- [x] 同一响应的 total 与实际 items 一致,无计数截断伪装;旧财务汇总不重复算作行动。 - [x] 同一响应的 total 与实际 items 一致,无计数截断伪装;旧财务汇总不重复算作行动。
- [x] 真实隔离库推进退回→重提→审批→组批→复核→登记,重复读取不写库,不依赖通知发送。 - [x] 真实隔离库推进退回→重提→审批→组批→复核→登记,重复读取不写库,不依赖通知发送。
- [x] PC/手机入口、错误与空态、查询刷新及最终联合工作树回归通过。 - [x] PC/手机入口、错误与空态、查询刷新及最终联合工作树回归通过。
- [ ] 独立只读 review、授权提交和部署后的受控验收,不以主会话本地测试抵扣。 - [x] 追加复核发现的手机非财务查询门修正;普通员工与单独 CEO 的冷进入、重读消失、错误重试及财务卡片不越权有浏览器回归。
- [x] 获批分笔提交推送,最终树与本地验证输入一致。
- [x] 追加身份隔离:双端同文档切账号的慢响应、失败重试、旧请求晚返回(含迟到 401 与甲→乙→甲)均不串显;旧身份请求取消,同身份正常刷新、既有冷进入和前缀刷新继续有效。
- [ ] 独立只读 review 和部署后的受控验收,不以主会话本地测试抵扣。
## Notes ## Notes
- 09-11 两轮主会话只读检查分别发现固定 key 串显、迟到 401 跨会话重试污染缓存;均在未修构建双端复现。沿原批准范围补身份 key 与请求取消,最新专项 10/10、联合 66/66、新构建及真实隔离业务链通过。原 6/62 和 NO_GO 证据保留;本地已测范围闭合不等于全局认证完成或生产已修复,独立复核与上线仍待完成。
- 复用 09-04/09-08 真实交接证据。本独立跨模块交付与详情一致性分工作树,不为每个页面/按钮新建 task。 - 复用 09-04/09-08 真实交接证据。本独立跨模块交付与详情一致性分工作树,不为每个页面/按钮新建 task。
@@ -15,12 +15,12 @@
"branch": null, "branch": null,
"base_branch": "main", "base_branch": "main",
"worktree_path": null, "worktree_path": null,
"commit": null, "commit": "fedcaed1a13cc3b32ee47e1d72c2b5ffa5512c57",
"pr_url": null, "pr_url": null,
"subtasks": [], "subtasks": [],
"children": [], "children": [],
"parent": null, "parent": null,
"relatedFiles": [], "relatedFiles": [],
"notes": "09-11本地实施完成,与09-04详情修复按补丁联合;135单测、39页面及真实隔离HTTP/双端状态演进通过,未调用真实银行/OCR或生产。基线fe025e65不含本次未提交改动;独立复核、授权提交推送、新鲜预检/配对恢复与部署验收待执行。证据与副本位置见implement,不归档。", "notes": "09-11复核dcde0d44发现旧请求迟到401污染旧身份缓存,双端甲→乙→甲已合成复现,原包NO_GO保留。沿原批准门户隔离范围接续取消:两页queryFn消费signal,经门户API传给HTTP,不改全局认证、角色或服务端。双端红测后最新专项10/10、联合66/66零重试、新构建/PWA及真实隔离业务链PASS,临时库/合成附件已清理;135单测按未变输入复用。新包postpush-cancellation,独立review仍PENDING_INLINE;需新输入交审、明确提交推送授权、新SHA执行时预检及另批部署/公网自动验收。本轮未提交推送、连接生产或部署,不声称全站认证缓存或真实过期会话已验证。",
"meta": {} "meta": {}
} }
+2 -2
查看文件
@@ -118,8 +118,8 @@ export type PortalHomePayload = {
teamStatusBoard: PortalTeamStatusItem[]; teamStatusBoard: PortalTeamStatusItem[];
}; };
export async function fetchPortalHome() { export async function fetchPortalHome(signal?: AbortSignal) {
return apiRequestData<PortalHomePayload>("/api/portal/home"); return apiRequestData<PortalHomePayload>("/api/portal/home", { signal });
} }
export async function fetchPortalTeamStatus() { export async function fetchPortalTeamStatus() {
+3 -3
查看文件
@@ -128,9 +128,9 @@ export function MobilePortalPage() {
}); });
const portalHomeQuery = useQuery({ const portalHomeQuery = useQuery({
enabled: canUseFinance, enabled: Boolean(me?.id),
queryKey: ["mobile", "portal", "home"], queryKey: ["mobile", "portal", "home", me?.id],
queryFn: () => fetchPortalHome(), queryFn: ({ signal }) => fetchPortalHome(signal),
staleTime: 0, staleTime: 0,
refetchOnMount: true, refetchOnMount: true,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
+1 -1
查看文件
@@ -687,7 +687,7 @@ function ConditionalWorkPanel({
export function PortalHomePage() { export function PortalHomePage() {
const { me, permissions } = useAuth(); const { me, permissions } = useAuth();
const [showWelcome, setShowWelcome] = useState(false); const [showWelcome, setShowWelcome] = useState(false);
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome(), staleTime: 0, refetchOnMount: true, refetchOnWindowFocus: true }); const portalQuery = useQuery({ enabled: Boolean(me?.id), queryKey: ["portal-home", me?.id], queryFn: ({ signal }) => fetchPortalHome(signal), staleTime: 0, refetchOnMount: true, refetchOnWindowFocus: true });
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]); const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
useEffect(() => { useEffect(() => {
+274 -15
查看文件
@@ -28,8 +28,10 @@ function envelope<T>(data: T) {
return { requestId: "visual-portal", data }; return { requestId: "visual-portal", data };
} }
function portalPayload(mode: "employee" | "admin" | "finance") { type PortalMode = "employee" | "admin" | "finance" | "ceo";
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利";
function portalPayload(mode: PortalMode) {
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : mode === "ceo" ? "合成复核人" : "王顺利";
const base = { const base = {
workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" }, workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" },
greeting: { name: displayName }, greeting: { name: displayName },
@@ -112,7 +114,7 @@ function portalPayload(mode: "employee" | "admin" | "finance") {
}; };
} }
async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "finance", handoffs?: () => PortalHomePayload["businessHandoffs"]) { async function mockVisualPortal(page: Page, mode: PortalMode, handoffs?: () => PortalHomePayload["businessHandoffs"]) {
await page.addInitScript(() => { await page.addInitScript(() => {
window.localStorage.removeItem("hyb_oa_show_welcome"); window.localStorage.removeItem("hyb_oa_show_welcome");
window.localStorage.removeItem("hyb_oa_onboarding_active"); window.localStorage.removeItem("hyb_oa_onboarding_active");
@@ -139,8 +141,8 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
return; return;
} }
if (pathName === "/api/me") { if (pathName === "/api/me") {
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利"; const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : mode === "ceo" ? "合成复核人" : "王顺利";
const employeeNo = mode === "employee" ? "HYD000003" : mode === "finance" ? "HYD000004" : "HYD000006"; const employeeNo = mode === "employee" ? "HYD000003" : mode === "finance" ? "HYD000004" : mode === "ceo" ? "SYN-CEO" : "HYD000006";
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@@ -149,7 +151,7 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
employeeNo, employeeNo,
name: displayName, name: displayName,
email: `${mode}@hyinda.com`, email: `${mode}@hyinda.com`,
roleCodes: mode === "employee" ? ["employee"] : mode === "finance" ? ["finance"] : ["admin"], roleCodes: [mode],
notificationPushEnabled: true, notificationPushEnabled: true,
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 }, profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 },
wecomBinding: null, wecomBinding: null,
@@ -194,6 +196,12 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
}); });
} }
function responseGate() {
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
return { pending, release };
}
test.describe("visual portal", () => { test.describe("visual portal", () => {
for (const mobile of [false, true]) { for (const mobile of [false, true]) {
test(`业务交接${mobile ? "手机" : "PC"}显示真实事项并在处理后刷新消失`, async ({ page }, testInfo) => { test(`业务交接${mobile ? "手机" : "PC"}显示真实事项并在处理后刷新消失`, async ({ page }, testInfo) => {
@@ -221,15 +229,266 @@ test.describe("visual portal", () => {
}); });
} }
test("员工退回待修改直接进入本人的报销,不依赖通知未读数", async ({ page }) => { for (const role of ["employee", "ceo"] as const) {
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项"); for (const mobile of [false, true]) {
await mockVisualPortal(page, "employee", () => ({ total: 1, items: [{ key: "reimbursement-edit:synthetic", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }] })); test(`非财务${role}的${mobile ? "手机" : "PC"}冷进入加载本人待办并在处理后消失`, async ({ page }, testInfo) => {
await login(page, EMPLOYEE); test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
await page.goto("/portal"); const handoff: NonNullable<PortalHomePayload["businessHandoffs"]>["items"][number] = role === "employee"
const row = page.getByRole("link").filter({ hasText: "报销退回待修改" }); ? { key: "reimbursement-edit:synthetic", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }
await expect(row).toHaveCount(1); : { key: "payment-review:synthetic", kind: "payment_review", reference: "PB-SYN", title: "付款批次待复核", nextStep: "由非制单人复核", ownerLabel: "非制单复核人", href: "/payment-batches?id=synthetic", mobileHref: "/mobile/payment-batches?id=synthetic" };
await expect(row).toHaveAttribute("href", "/reimbursements/synthetic"); let items = [handoff];
}); await mockVisualPortal(page, role, () => ({ total: items.length, items }));
await page.context().addCookies([{ name: "has_session", value: "1", url: String(testInfo.project.use.baseURL) }]);
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
await page.goto(mobile ? "/mobile" : "/portal");
const row = page.getByRole("link").filter({ hasText: handoff.title });
await expect(row).toHaveCount(1);
await expect(row).toHaveAttribute("href", mobile ? handoff.mobileHref : handoff.href);
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
await expect(page.getByRole("link").filter({ hasText: "报销待组建付款批次" })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("nonfinance-handoffs.png"), fullPage: true });
items = [];
const refreshed = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
await page.reload();
await refreshed;
await expect(row).toHaveCount(0);
await expect(page.getByTestId("mobile-business-handoffs")).toHaveCount(0);
});
}
test(`非财务${role}手机待办加载失败可重试,不把错误当空态`, async ({ page }, testInfo) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
const handoff: NonNullable<PortalHomePayload["businessHandoffs"]>["items"][number] = role === "employee"
? { key: "reimbursement-edit:retry", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/retry", mobileHref: "/mobile/reimbursements/retry" }
: { key: "payment-review:retry", kind: "payment_review", reference: "PB-SYN", title: "付款批次待复核", nextStep: "由非制单人复核", ownerLabel: "非制单复核人", href: "/payment-batches?id=retry", mobileHref: "/mobile/payment-batches?id=retry" };
await mockVisualPortal(page, role, () => ({ total: 1, items: [handoff] }));
let unavailable = true;
await page.route("**/api/portal/home", async (route) => {
if (unavailable) await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "synthetic unavailable" } }) });
else await route.fallback();
});
await page.context().addCookies([{ name: "has_session", value: "1", url: String(testInfo.project.use.baseURL) }]);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile");
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-business-handoffs")).toHaveCount(0);
unavailable = false;
await page.getByRole("button", { name: "重试加载待办", exact: true }).click();
await expect(page.getByRole("link").filter({ hasText: handoff.title })).toBeVisible();
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
});
}
for (const mobile of [false, true]) {
for (const scenario of ["slow", "failure", "late", "late401"] as const) {
test(`门户身份隔离 ${mobile ? "手机" : "PC"} 同文档切账号 ${scenario}`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成账号,不访问公网");
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
let actor: "first" | "second" = "first";
let holdOldRequest = false;
let oldRequestStarted = false;
let oldRequestFinished = false;
let oldRequestAborted = false;
let holdFirstReturn = false;
let firstReturnStarted = false;
let refreshRequests = 0;
let secondFails = scenario === "failure";
const requestCounts = { first: 0, second: 0 };
const secondResponse = responseGate();
const oldResponse = responseGate();
const firstReturnResponse = responseGate();
const handoff = (identity: "first" | "second") => ({
key: `reimbursement-edit:synthetic-cache-${identity}`, kind: "reimbursement_edit" as const,
reference: `BX-SYN-CACHE-${identity}`, title: identity === "first" ? "合成甲待办" : "合成乙待办",
nextStep: "修改后重新提交", ownerLabel: "报销人",
href: `/reimbursements/synthetic-cache-${identity}`, mobileHref: `/mobile/reimbursements/synthetic-cache-${identity}`,
});
await mockVisualPortal(page, "employee");
const requestActor = (authorization?: string) => authorization === "Bearer synthetic-first" ? "first" : "second";
page.on("requestfailed", (request) => {
if (oldRequestStarted && new URL(request.url()).pathname === "/api/portal/home" && requestActor(request.headers().authorization) === "first") oldRequestAborted = true;
});
await page.route("**/api/auth/login", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: `synthetic-${actor}`, expiresIn: 900, mustChangePassword: false })) });
});
await page.route("**/api/auth/refresh", async (route) => {
refreshRequests += 1;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: `synthetic-${actor}`, expiresIn: 900, mustChangePassword: false })) });
});
await page.route("**/api/me", async (route) => {
const profileActor = requestActor(route.request().headers().authorization);
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({
id: `synthetic-cache-${profileActor}`, employeeNo: `SYN-${profileActor}`, name: profileActor === "first" ? "合成员工甲" : "合成员工乙",
email: `${profileActor}@example.test`, roleCodes: ["employee"], notificationPushEnabled: true, wecomBinding: null,
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 },
})) });
});
await page.route("**/api/portal/home", async (route) => {
const requestedActor = requestActor(route.request().headers().authorization);
requestCounts[requestedActor] += 1;
const delayedOldRequest = requestedActor === "first" && holdOldRequest;
if (delayedOldRequest) {
oldRequestStarted = true;
await oldResponse.pending;
if (scenario === "late401") {
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_002" } }) });
oldRequestFinished = true;
return;
}
}
if (requestedActor === "first" && holdFirstReturn) {
firstReturnStarted = true;
await firstReturnResponse.pending;
}
if (requestedActor === "second") await secondResponse.pending;
if (requestedActor === "second" && secondFails) {
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "synthetic unavailable" } }) });
} else {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({
...portalPayload("employee"), businessHandoffs: { total: 1, items: [handoff(requestedActor)] },
pendingTasks: { count: 0, href: "/workflow/tasks" }, todoSummary: null, weeklyReport: null, projectSummary: null,
})) });
}
if (delayedOldRequest) oldRequestFinished = true;
});
try {
await page.goto(`/login?redirect=${mobile ? "/mobile" : "/portal"}`);
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly123!");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(mobile ? /\/mobile$/ : /\/portal$/);
const firstRow = page.getByRole("link").filter({ hasText: handoff("first").title });
const secondRow = page.getByRole("link").filter({ hasText: handoff("second").title });
await expect(firstRow).toBeVisible();
await page.evaluate(() => { Reflect.set(window, "__portalSwitchDocument", "same-document"); });
if (scenario === "late" || scenario === "late401") {
holdOldRequest = true;
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
});
await expect.poll(() => oldRequestStarted).toBe(true);
}
if (mobile) {
await page.locator('a[href="/mobile/me"]').last().click();
await page.getByRole("button", { name: "退出登录", exact: true }).click();
} else {
await page.getByTestId("topbar-profile-trigger").first().click();
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
}
await expect(page).toHaveURL(/\/login/);
actor = "second";
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly123!");
await page.getByTestId("login-submit").click();
if (mobile) {
await expect(page.getByTestId("mobile-me-profile-name")).toHaveText("合成员工乙");
await page.locator('a[href="/mobile"]').last().click();
} else {
await expect(page.getByTestId("topbar-profile-trigger").first()).toContainText("合成员工乙");
}
await expect.poll(() => requestCounts.second).toBeGreaterThan(0);
expect(await page.evaluate(() => Reflect.get(window, "__portalSwitchDocument"))).toBe("same-document");
await expect(firstRow).toHaveCount(0, { timeout: 5000 });
await expect(secondRow).toHaveCount(0);
secondResponse.release();
if (scenario === "failure") {
const retry = mobile ? page.getByRole("button", { name: "重试加载待办", exact: true }) : page.getByTestId("portal-load-retry");
await expect(retry).toBeVisible();
await expect(firstRow).toHaveCount(0);
secondFails = false;
await retry.click();
}
await expect(secondRow).toBeVisible();
if (scenario === "late" || scenario === "late401") {
if (scenario === "late401") await expect.poll(() => oldRequestAborted, { timeout: 5000 }).toBe(true);
const refreshRequestsBeforeOldResponse = refreshRequests;
oldResponse.release();
await expect.poll(() => oldRequestFinished).toBe(true);
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
if (scenario === "late401") expect(refreshRequests).toBe(refreshRequestsBeforeOldResponse);
}
await expect(firstRow).toHaveCount(0);
await expect(secondRow).toHaveCount(1);
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
if (scenario === "late401") {
if (mobile) {
await page.locator('a[href="/mobile/me"]').last().click();
await page.getByRole("button", { name: "退出登录", exact: true }).click();
} else {
await page.getByTestId("topbar-profile-trigger").first().click();
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
}
await expect(page).toHaveURL(/\/login/);
actor = "first";
holdOldRequest = false;
holdFirstReturn = true;
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly123!");
await page.getByTestId("login-submit").click();
if (mobile) {
await expect(page.getByTestId("mobile-me-profile-name")).toHaveText("合成员工甲");
await page.locator('a[href="/mobile"]').last().click();
} else {
await expect(page.getByTestId("topbar-profile-trigger").first()).toContainText("合成员工甲");
}
await expect.poll(() => firstReturnStarted).toBe(true);
expect(await page.evaluate(() => Reflect.get(window, "__portalSwitchDocument"))).toBe("same-document");
await expect(secondRow).toHaveCount(0);
firstReturnResponse.release();
await expect(firstRow).toBeVisible();
await expect(secondRow).toHaveCount(0);
}
} finally {
secondResponse.release();
oldResponse.release();
firstReturnResponse.release();
await page.unrouteAll({ behavior: "wait" });
}
});
}
}
for (const mobile of [false, true]) {
test(`门户身份隔离 ${mobile ? "手机" : "PC"} 同身份401刷新仍可用`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成认证,不访问公网");
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
let portalRequests = 0;
let refreshRequests = 0;
await mockVisualPortal(page, "employee", () => ({ total: 0, items: [] }));
await page.route("**/api/auth/refresh", async (route) => {
refreshRequests += 1;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) });
});
await page.route("**/api/portal/home", async (route) => {
portalRequests += 1;
if (portalRequests === 1) {
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_002" } }) });
} else {
await route.fallback();
}
});
await page.goto(`/login?redirect=${mobile ? "/mobile" : "/portal"}`);
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly123!");
const loaded = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home" && response.status() === 200);
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(mobile ? /\/mobile$/ : /\/portal$/);
expect((await (await loaded).json()).data.businessHandoffs.total).toBe(0);
await expect.poll(() => portalRequests).toBe(2);
expect(refreshRequests).toBe(1);
if (mobile) {
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toHaveCount(0);
} else {
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
await expect(page.getByTestId("portal-load-error")).toHaveCount(0);
}
});
}
test("超过八条业务事项时计数与展示不截断", async ({ page }) => { test("超过八条业务事项时计数与展示不截断", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项"); test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
+76 -1
查看文件
@@ -85,7 +85,10 @@ async function main() {
await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" }); await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" });
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
const envelope = (data) => ({ requestId: "synthetic-http", data }); const envelope = (data) => ({ requestId: "synthetic-http", data });
app.get("/api/me", { preHandler: app.authenticate }, async (request) => envelope({ id: request.employee.id, employeeNo: request.employee.employee_no, name: "synthetic", email: request.employee.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: request.employee.roleCodes, notificationPushEnabled: false })); app.get("/api/me", { preHandler: app.authenticate }, async (request) => {
const profile = await prisma.employee.findUniqueOrThrow({ where: { id: request.employee.id }, select: { employee_no: true, email: true } });
return envelope({ id: request.employee.id, employeeNo: profile.employee_no, name: "synthetic", email: profile.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: request.employee.roleCodes, notificationPushEnabled: false });
});
app.get("/api/system/navigation", async () => envelope({ workspace: { id: "synthetic", profile: "small_team_internal", orgMode: "single_company", moduleVisibility: {} }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [] })); app.get("/api/system/navigation", async () => envelope({ workspace: { id: "synthetic", profile: "small_team_internal", orgMode: "single_company", moduleVisibility: {} }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [] }));
app.get("/api/notifications", async () => envelope({ items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 })); app.get("/api/notifications", async () => envelope({ items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 }));
await app.register(serverRequire("./src/routes/expenseTypes"), { prefix: "/api" }); await app.register(serverRequire("./src/routes/expenseTypes"), { prefix: "/api" });
@@ -134,6 +137,78 @@ async function main() {
}; };
assert.deepEqual(await getHandoffs(), await getHandoffs()); assert.deepEqual(await getHandoffs(), await getHandoffs());
assert.equal(await prisma.auditLog.count(), auditCount, "handoff reads do not create audit or workflow writes"); assert.equal(await prisma.auditLog.count(), auditCount, "handoff reads do not create audit or workflow writes");
if (browser && preview && ["reimbursement_edit", "payment_review"].includes(kind)
&& [employee.id, approvers.ceo.id].includes(actor.id)) {
const clientRequire = createRequire(path.join(root, "client/package.json"));
const { expect } = clientRequire("@playwright/test");
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
const context = await browser.newContext({ serviceWorkers: "block", viewport: { width: 390, height: 844 } });
try {
const fingerprint = `synthetic-handoff-${randomUUID()}`;
await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
const login = await context.request.post(`${uiOrigin}/api/auth/login`, {
headers: { "x-device-fingerprint": fingerprint }, data: { identifier: actor.email, password: loginPassword },
});
assert.equal(login.status(), 200, "non-finance mobile real password login");
const page = await context.newPage();
page.setDefaultTimeout(20000);
const loaded = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
await page.goto(`${uiOrigin}/mobile`);
assert.equal((await loaded).status(), 200, "non-finance mobile cold entry actually loads handoffs");
const href = kind === "reimbursement_edit" ? `/mobile/reimbursements/${entityId}` : `/mobile/payment-batches?id=${entityId}`;
const row = page.getByTestId("mobile-business-handoffs").locator(`a[href="${href}"]`);
await expect(row).toHaveCount(expectedCount);
if (expectedCount) await expect(row).toBeVisible();
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
passed.push(`handoffs: non-finance mobile cold entry with real auth/HTTP ${kind} count=${expectedCount}, no finance card`);
if (kind === "reimbursement_edit" && expectedCount === 1 && actor.id === employee.id) {
await page.evaluate(() => { Reflect.set(window, "__realHandoffSwitchDocument", "same-document"); });
await page.locator('a[href="/mobile/me"]').last().click();
await page.getByRole("button", { name: "退出登录", exact: true }).click();
await expect(page).toHaveURL(/\/login/);
await page.getByTestId("login-identifier").fill(approvers.ceo.email);
await page.getByTestId("login-password").fill(loginPassword);
const secondLogin = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/auth/login");
const secondProfile = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/me");
await page.getByTestId("login-submit").click();
assert.equal((await secondLogin).status(), 200, "second actor UI password login");
const profileResponse = await secondProfile;
assert.equal(profileResponse.status(), 200, "second actor profile loaded");
assert.equal((await profileResponse.json()).data.id === approvers.ceo.id, true, "second actor identity matches");
await expect(page).toHaveURL(/\/mobile\/me$/);
await expect(page.getByTestId("mobile-me-profile-copy")).toContainText(approvers.ceo.employee_no);
let releaseResponse;
let secondActorResponseVerified = false;
const responseGate = new Promise((resolve) => { releaseResponse = resolve; });
await page.route("**/api/portal/home", async (route) => {
const response = await route.fetch();
assert.equal(response.status(), 200, "second actor real portal response");
const data = (await response.json()).data.businessHandoffs;
assert.equal(data.items.some((item) => item.href === `/reimbursements/${entityId}`), false);
secondActorResponseVerified = true;
await responseGate;
await route.fulfill({ response });
});
try {
await page.locator('a[href="/mobile"]').last().click();
await expect.poll(() => secondActorResponseVerified).toBe(true);
assert.equal(await page.evaluate(() => Reflect.get(window, "__realHandoffSwitchDocument")), "same-document");
await expect(row).toHaveCount(0);
const refreshed = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
releaseResponse();
await refreshed;
await expect(row).toHaveCount(0);
passed.push("handoffs: same-document employee-to-CEO mobile switch with real password login and isolated HTTP, no former actor handoff while response is held");
} finally {
releaseResponse();
await page.unrouteAll({ behavior: "wait" });
}
}
} finally {
await context.close();
}
}
}; };
const bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor); const bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor);
const objects = [ const objects = [