From 76f2d9f22a465a4504446e272d1c1392b7e78998 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Wed, 8 Jul 2026 20:45:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=A2=9E=E5=8A=A0=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...desktop-release-stabilization-checklist.md | 21 +- docs/desktop-local-cache-plan.md | 180 ++++++++++++++ docs/desktop-project-plan.md | 41 +++- frontend/scripts/verify-desktop-release.mjs | 7 + frontend/scripts/verify-runtime-boundary.mjs | 3 + frontend/src/api/auth.test.ts | 22 ++ frontend/src/api/auth.ts | 2 +- frontend/src/api/axios.test.ts | 154 +++++++++++- frontend/src/api/axios.ts | 224 +++++++++++++++++- frontend/src/api/dashboard.ts | 22 +- frontend/src/api/members.ts | 11 +- frontend/src/api/overview.ts | 5 +- frontend/src/api/projectPermissions.ts | 47 +++- frontend/src/api/sites.ts | 1 + frontend/src/api/studies.ts | 16 +- frontend/src/runtime/clientRuntime.ts | 3 + frontend/src/runtime/desktopDataCache.test.ts | 59 +++++ frontend/src/runtime/desktopDataCache.ts | 140 +++++++++++ frontend/src/runtime/index.ts | 13 + frontend/src/store/auth.ts | 5 +- 20 files changed, 926 insertions(+), 50 deletions(-) create mode 100644 docs/desktop-local-cache-plan.md create mode 100644 frontend/src/api/auth.test.ts create mode 100644 frontend/src/runtime/desktopDataCache.test.ts create mode 100644 frontend/src/runtime/desktopDataCache.ts diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index c8047308..27b54afa 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -2,9 +2,9 @@ 状态: `active` 适用范围: Web 与 macOS Desktop 统一客户端发布 -最后更新: `2026-07-02` +最后更新: `2026-07-08` -本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。 +本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。当前允许按 `docs/desktop-local-cache-plan.md` 引入在线辅助本地缓存,但不引入离线登录、离线写入、本地业务权威数据、内嵌后端服务或离线同步。 ## 1. 发布链路门禁 @@ -51,6 +51,9 @@ npm run desktop:build:app - [ ] 前端源码不通过 query string 传递 token。 - [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。 - [ ] 登录表单密码不写入 `localStorage` 或 `sessionStorage`;Web 端只使用浏览器凭据管理能力,Desktop 端只使用系统凭据库。 +- [ ] 本地缓存能力只通过 `frontend/src/runtime/desktopDataCache.ts` 或后续同名 runtime 入口暴露,业务模块不直接调用 Tauri 存储 API、SQLite、IndexedDB、Cache Storage 或文件系统。 +- [ ] 本地缓存命名空间包含 server origin、user id 和 cache schema version。 +- [ ] 本地缓存不保存 token、密码、Authorization/Bearer 文本、下载凭据、临时授权 URL 或系统通知正文。 - [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。 - [ ] 通知 capability 只暴露权限查询、权限请求和发送通知,不使用 `notification:default`。 - [ ] opener capability 只允许打开 `$TEMP/ctms-desktop/**` 下的临时文件,不开放 URL 或 reveal 权限。 @@ -65,6 +68,7 @@ npm run desktop:build:app - [ ] 密码不出现在 URL、日志、系统通知正文、诊断信息或明文浏览器存储中。 - [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。 - [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。 +- [ ] 本地缓存只作为服务端响应副本,mutation、审批、权限判断和审计判断仍以后端结果为准。 - [ ] Web 运行时不直接导入 Tauri API。 ## 3. 端到端回归矩阵 @@ -77,6 +81,9 @@ npm run desktop:build:app | 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | | 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 | | 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 | +| 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 | +| 本地缓存清理 | 必测 | 必测 | 登出、切换服务器、切换用户、401/403 后旧命名空间不可读取 | +| mutation 缓存失效 | 必测 | 必测 | 新增、编辑、删除或审批成功后相关列表、详情、统计和图表缓存失效 | | 附件上传 | 必测 | 必测 | Web 使用浏览器文件选择,Desktop 使用原生选择 | | 附件下载/保存/打开 | 必测 | 必测 | 使用 Authorization header;无 `?token=` | | 临时文件清理 | 不适用 | 必测 | 启动时清理 `$TEMP/ctms-desktop/**` | @@ -96,6 +103,7 @@ npm run desktop:build:app - [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。 - [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。 - [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。 +- [ ] 系统偏好或个人中心提供本地缓存记录数、容量和最近清理时间,且支持手动清理。 - [ ] 通知开关显示 OS 权限状态。 - [ ] 手动检查更新能反馈“已是最新版本”、未启用更新或检查失败。 - [ ] 关键弹窗、表单、按钮在最小窗口尺寸 `1180x760` 下不重叠、不溢出。 @@ -103,10 +111,11 @@ npm run desktop:build:app ## 5. 不允许项 -- [ ] 不实现离线登录、离线浏览、离线队列或离线同步。 -- [ ] 不在桌面端保存 CTMS 业务数据副本。 -- [ ] 不内嵌 FastAPI、PostgreSQL、SQLite 或本地业务 API 镜像。 -- [ ] 不绕过后端做本地权限裁决或本地审计回放。 +- [ ] 不实现离线登录、离线写入、离线队列或离线同步。 +- [ ] 不在 `/me` 校验通过前展示业务缓存。 +- [ ] 不把本地缓存作为 CTMS 业务权威数据或本地优先数据源。 +- [ ] 不内嵌 FastAPI、PostgreSQL 或本地业务 API 镜像。 +- [ ] 不绕过后端做本地权限裁决、本地审计判定或审计回放。 ## 6. 2026-07-01 收尾验证记录 diff --git a/docs/desktop-local-cache-plan.md b/docs/desktop-local-cache-plan.md new file mode 100644 index 00000000..f9a9949b --- /dev/null +++ b/docs/desktop-local-cache-plan.md @@ -0,0 +1,180 @@ +# CTMS 桌面端本地缓存执行方案 + +## 目标 + +桌面端新增本地缓存的目标是降低重复网络请求、改善页面回访和 App 重启后的等待时间。当前项目不涉及受试者隐私问题,后端返回的 CTMS 业务 GET 数据均可进入本地缓存候选范围,但缓存仍只是服务端响应副本,不是业务权威数据。 + +本方案不改变以下边界: + +- 登录、会话恢复和用户身份确认仍必须依赖后端 token 校验和 `/me`。 +- 新增、编辑、删除、审批、导入、导出、通知 ack、权限判断和审计判断仍必须以后端结果为准。 +- 不实现离线登录、离线写入队列、冲突解决、本地优先工作流、本地 API 镜像或内嵌后端服务。 +- token、登录密码、附件下载凭据、临时授权 URL、Authorization/Bearer 文本不得写入本地缓存、URL、日志或系统通知正文。 + +## 缓存范围 + +默认可缓存: + +- 字典、枚举、菜单、权限摘要、用户可见组织/中心、应用配置、系统元数据。 +- 项目、任务、机构、用户、角色、报表、监控页面等业务 GET 响应。 +- 页面查询结果、分页列表、详情页只读数据和图表数据。 + +默认不缓存: + +- POST、PUT、PATCH、DELETE 等 mutation 请求响应,除非只用于立即失效相关 GET 缓存。 +- 文件下载二进制正文、一次性下载链接、带签名的临时 URL。 +- token、密码、验证码、MFA 信息、Authorization header、Cookie 原文。 +- 系统通知正文和可能包含临时凭据的 release notes 或错误日志。 + +## 架构约束 + +- 统一入口命名为 `desktopDataCache`,位于 `frontend/src/runtime/`,并通过 `clientRuntime` 暴露。 +- 业务模块不得直接调用 Tauri 存储 API、文件系统、SQLite、IndexedDB 或 Cache Storage 来实现桌面缓存。 +- Web 与 Desktop 共用业务代码,平台差异只在 runtime 适配层之后收敛。 +- Tauri command 只做窄职责存储读写、清理、容量统计或目录定位,不包含 CTMS 业务规则。 +- 如果引入新的 Tauri command、capability、CSP 或存储插件,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单。 + +## 缓存命名和数据模型 + +缓存命名空间必须包含: + +- `serverOrigin`:规范化后的后端服务地址,不包含用户名、密码、token 或 query 凭据。 +- `userId`:后端 `/me` 确认后的用户标识。 +- `schemaVersion`:缓存结构版本,用于升级时整体失效。 +- `requestSignature`:请求方法、路径、排序后的 query、稳定序列化后的 body 摘要和必要的业务上下文。 + +每条缓存记录至少包含: + +- `payload`:后端响应副本。 +- `status`:HTTP 状态码,仅缓存成功的可展示响应。 +- `headers`:只保存白名单响应头,例如 `ETag`、`Last-Modified`、`Cache-Control`、业务版本头。 +- `createdAt`、`updatedAt`、`expiresAt`、`lastAccessedAt`。 +- `sourceEndpoint` 和 `cacheTags`,用于诊断和精准失效。 + +## 缓存策略 + +第一优先级是请求减量: + +- 同一会话内相同 GET 请求合并为一个 in-flight promise。 +- 页面返回、tab 切换和重复组件挂载时优先命中短时内存缓存。 +- 页面可使用 stale-while-revalidate:先显示本地副本,再后台刷新,刷新失败时保留旧数据并显示可恢复错误。 + +第二优先级是持久化缓存: + +- 仅在后端会话恢复和 `/me` 成功后启用业务缓存读取。 +- 默认缓存 GET 响应;mutation 成功后通过 tags 或 endpoint 规则失效相关 GET 缓存。 +- TTL 先按数据类型配置,缺省值建议 5 分钟;字典和元数据可延长到 24 小时;高频业务列表建议 1 到 5 分钟。 +- 设置总容量上限和 LRU 清理策略,避免缓存无限增长。 + +第三优先级是 HTTP 条件请求: + +- 后端为稳定数据提供 `ETag` 或 `Last-Modified`。 +- 客户端对有验证器的缓存请求带 `If-None-Match` 或 `If-Modified-Since`。 +- 收到 304 时刷新缓存元数据,不重复下载 payload。 + +## 失效和清理 + +必须整体清理: + +- 用户登出。 +- 切换服务器。 +- 切换用户。 +- 后端返回 401 或 403。 +- `/me` 返回的用户标识、角色、权限版本或租户上下文变化。 +- `schemaVersion` 升级。 + +必须精准失效: + +- mutation 成功后,清理相关详情、列表、统计和图表缓存。 +- 权限、角色、组织、项目状态变化后,清理依赖这些上下文的页面缓存。 +- 后端返回明确业务版本头或失效事件时,按 tags 清理。 + +应提供用户入口: + +- 系统偏好或个人中心诊断信息展示缓存大小、记录数、最近清理时间。 +- 提供“清理本地缓存”按钮。 +- 清理提示只描述缓存状态,不包含具体业务内容。 + +## 分阶段实施 + +### 当前落地状态 + +2026-07-08 已完成阶段 1 的首批实现: + +- 新增 `frontend/src/runtime/desktopDataCache.ts`,提供内存级缓存 scope、TTL、tag 失效、清理和统计能力,并通过 `clientRuntime.dataCache` 暴露。 +- `frontend/src/api/axios.ts` 已支持 GET 并发去重、显式 GET 内存缓存、mutation 成功后默认清理缓存、401/403 清理缓存、服务器切换清理缓存。 +- 缓存清理、scope 切换和 tag 失效会递增 generation;GET 响应返回时若 generation 已变化,不再写入缓存,避免旧用户、旧服务器或 mutation 前响应回填到新缓存空间。 +- `/api/v1/auth/login-key` 明确禁用 GET 去重,避免复用登录 challenge。 +- GET 去重和缓存 key 已包含 schema version、server baseURL、请求参数、非敏感请求头、responseType、paramsSerializer 和 withCredentials;包含敏感 header 或 axios auth 的请求不会进入去重或缓存。 +- 登录态已在 `/me` 成功后绑定缓存 scope,登出时清空 scope 和缓存。 +- 首批缓存接入范围包括项目列表/详情、项目配置、权限摘要、系统权限、权限模板、机构、成员、项目概览和仪表盘摘要。 +- `npm run runtime:check` 和 `npm run desktop:release:check` 已增加底层缓存 API 边界检查,业务模块不得直接调用 `indexedDB`、`caches.open`、`CacheStorage` 或 SQLite 入口。 + +仍未完成: + +- 持久化缓存尚未落地,当前缓存只存在于本次前端运行内存中。 +- 后端 `ETag` / `Last-Modified` 和客户端 304 处理尚未落地。 +- 系统偏好或个人中心缓存诊断与手动清理入口尚未落地。 +- mutation 失效当前以默认全量清理为主,后续再按 tags 收敛为精准失效。 + +### 阶段 0:请求观测和缓存清单 + +- 梳理 `frontend/src/api/`、Pinia store 和高频页面的 GET 请求。 +- 标记每个接口的缓存 tags、TTL、mutation 失效关系和是否需要后端 ETag。 +- 记录启动、登录后首页、常用列表和详情页的请求数量作为基线。 + +### 阶段 1:请求去重和内存缓存 + +- 在 API 客户端层增加 GET 请求 in-flight 去重。 +- 增加短 TTL 内存缓存,先覆盖字典、菜单、权限摘要、组织/中心和常用列表。 +- 增加单元测试覆盖请求合并、TTL 过期、mutation 后失效。 + +### 阶段 2:runtime 持久化缓存 + +- 在 `frontend/src/runtime/desktopDataCache.ts` 定义平台无关接口。 +- Web 端提供内存或 IndexedDB 实现;Desktop 端通过 runtime 封装 IndexedDB、Cache Storage 或 Tauri app data 存储。 +- 增加命名空间、容量限制、LRU、schema version、手动清理和诊断 API。 +- 更新 `runtime:check` 和桌面发布检查,禁止业务页面直接使用底层缓存存储。 + +### 阶段 3:页面接入 + +- 先接入基础数据和全局布局依赖接口。 +- 再接入项目列表、任务列表、报表图表、详情页只读数据。 +- 为 mutation 建立集中失效规则,避免页面各自手写清理逻辑。 + +### 阶段 4:后端条件请求 + +- 为稳定 GET 接口补 `ETag` 或 `Last-Modified`。 +- 前端 API 客户端接入 304 处理。 +- 增加后端和前端测试,确认 304 不改变业务数据语义。 + +### 阶段 5:诊断、验收和发布门禁 + +- 在个人中心或系统偏好加入缓存诊断和清理入口。 +- 验证登出、切换服务器、切换用户、401/403、权限变化和版本升级清理行为。 +- 执行相关质量门禁,并把新增缓存边界纳入发布检查清单。 + +## 验收标准 + +- App 重启并完成 `/me` 校验后,已访问过的常用页面可以先展示缓存再后台刷新。 +- 同一路由重复进入、组件重复挂载、多个组件请求同一资源时,不产生重复并发请求。 +- 登出、切换服务器、切换用户、401/403 后不能读取旧命名空间缓存。 +- mutation 成功后相关列表、详情、统计和图表缓存被失效或刷新。 +- token、密码、Authorization/Bearer、下载凭据不会出现在缓存文件、IndexedDB、日志、URL 或通知正文中。 +- 业务模块不直接调用 Tauri API、IndexedDB、SQLite、Cache Storage 或文件系统实现缓存。 + +## 质量门禁 + +本地缓存相关实现至少执行: + +```bash +cd frontend +npm run runtime:check +npm run desktop:release:check +npm run ui:contract +npm run type-check +npm run test:unit +npm run build +``` + +如果新增或修改后端条件请求、缓存头、权限版本或失效事件,还需执行对应后端测试。若新增 Tauri command、capability、CSP 或存储插件,还需执行 `npm run desktop:build:app` 并在真实 macOS `.app` 中验证缓存清理和诊断入口。 diff --git a/docs/desktop-project-plan.md b/docs/desktop-project-plan.md index 561a1747..b6f6bf23 100644 --- a/docs/desktop-project-plan.md +++ b/docs/desktop-project-plan.md @@ -11,10 +11,10 @@ - 技术路线固定为 Tauri。 - 第一开发目标是 macOS 桌面端。 - Windows 仍只作为第二阶段兼容性验证目标,未获明确批准前不发布正式安装包。 -- 当前桌面端工作只允许在第一、二阶段边界内做修复、稳定化、体验收口和发布准备,不新增第三阶段能力。 -- 不做离线功能。 +- 当前桌面端工作只允许在第一、二阶段边界内做修复、稳定化、体验收口、发布准备,以及本文档明确批准的在线辅助本地缓存;不新增独立第三阶段桌面产品线。 +- 不做离线登录、离线写入、离线同步或本地优先工作流;本地缓存只作为已认证在线客户端的体验加速能力。 - 不在桌面 App 内嵌本地后端服务。 -- 不在桌面 App 内嵌或分发本地数据库来保存 CTMS 业务数据。 +- 允许桌面端保存可清除的服务端响应缓存;缓存不是业务权威数据,不得替代后端持久化、权限裁决或审计判断。 - 不实现离线同步、冲突解决、本地业务数据队列、本地优先工作流。 - 不把 CTMS 业务 UI 拆成一套独立的桌面端产品;除非桌面能力确实需要小范围适配层。 - FastAPI 后端仍是业务权威来源。权限裁决、审计判断、认证、业务数据持久化都保持在服务端。 @@ -38,15 +38,17 @@ - 修复既有 Tauri、运行时适配层、文件、通知、凭据、更新、菜单/快捷键和打包问题。 - 稳定 Web 与 Desktop 共用业务代码,确保平台差异继续收敛在 `frontend/src/runtime/` 后面。 +- 按 [`desktop-local-cache-plan.md`](desktop-local-cache-plan.md) 推进在线辅助本地缓存、请求去重、条件请求和缓存失效能力。 - 完成 macOS 正式发布前的签名、公证、updater 签名、制品发布、CI 门禁和人工回归。 - 做 Windows 第二阶段兼容性验证,但不发布正式 Windows 安装包。 仍然不允许: -- 离线登录、离线浏览、离线队列、离线同步或本地优先工作流。 -- 本地 PostgreSQL、SQLite、IndexedDB 业务数据缓存或本地 API 镜像。 +- 离线登录、离线写入、离线队列、离线同步或本地优先工作流。 +- 未完成后端会话恢复、token 校验和 `/me` 身份确认前展示业务缓存。 +- 本地 PostgreSQL、本地 API 镜像、内嵌业务后端,或把 SQLite/IndexedDB/Cache Storage/Tauri app data 目录作为业务数据库使用。 - 内嵌 Python/FastAPI 后端服务。 -- 绕过后端做本地权限裁决、本地审计缓存或审计回放。 +- 绕过后端做本地权限裁决、本地审计判定、审计回放或写入补偿。 - 为桌面端复制或重写一套独立业务 UI。 ## 架构方向 @@ -66,6 +68,7 @@ - `updates`:隔离桌面自动更新检查与安装入口。 - `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。 - `desktopMenu` 和 `desktopUiPreferences`:承接桌面菜单命令、收藏模块等桌面体验状态。 +- `desktopDataCache`:承接桌面端在线辅助本地缓存、缓存命名空间、失效、清理和诊断;业务模块不得直接使用 Tauri 存储 API、IndexedDB、SQLite 或文件系统实现桌面缓存。 - `clientRuntime`:作为业务侧获取平台能力的聚合入口。 业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。 @@ -77,6 +80,9 @@ - 认证与授权决策保留在后端。 - 审计敏感决策保留在后端。 - 敏感凭据必须继续使用明确批准的安全存储方案;网页端密码只能交给浏览器凭据管理能力,桌面端密码只能交给系统凭据库。 +- token、登录密码、附件下载凭据和临时授权信息不得进入本地业务缓存、URL、日志或系统通知正文。 +- 本地业务缓存必须按 `server origin + user id + cache schema version` 隔离;登出、切换服务器、切换用户、后端返回 401/403、权限版本变化或缓存结构升级时必须清理或失效。 +- 本地缓存命中只能用于展示后端曾返回的数据副本;新增、编辑、删除、审批、权限判断和审计判断仍必须请求后端并以后端结果为准。 - 不向前端暴露宽泛文件系统访问权限。 - Tauri 权限保持最小化,并按功能精确授权。 - 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。 @@ -173,7 +179,24 @@ - macOS `.app` 在 `1180x760` 下的登录页、服务器设置、个人中心、系统偏好和更新弹窗实际布局。 - 系统通知权限拒绝后的开关状态、权限提示和错误提示是否与 OS 状态一致。 -如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。 +## 2026-07-08 本地缓存边界调整与执行方案 + +经当前项目边界复核,桌面端允许新增在线辅助本地缓存,用于减少重复请求、缩短页面回访和 App 重启后的数据展示等待时间。该能力不改变 CTMS 的业务权威边界:FastAPI 后端仍是认证、授权、审计和业务持久化的唯一权威来源。 + +执行方案以 [`desktop-local-cache-plan.md`](desktop-local-cache-plan.md) 为准,实施顺序为: + +1. 建立请求观测和缓存清单,确认高频 GET 接口、页面冷启动接口、基础数据接口和 mutation 后需要失效的资源。 +2. 先实现请求去重、短时内存缓存和页面级 stale-while-revalidate,减少同一会话内重复拉取。 +3. 在 `frontend/src/runtime/` 增加 `desktopDataCache` 适配层,统一提供缓存读写、命名空间、TTL、容量上限、失效、清理和诊断能力。 +4. 增加持久化缓存,默认只缓存后端 GET 响应和明确标记可缓存的数据;缓存 key 必须包含 server origin、user id、请求签名和 cache schema version。 +5. 推进后端 HTTP 条件请求支持,优先为字典、菜单、权限摘要、组织/中心、项目摘要等稳定数据提供 `ETag` 或 `Last-Modified`,客户端使用 `If-None-Match` 或 `If-Modified-Since` 降低 304 场景的数据传输。 +6. 建立统一失效机制:登出、切换服务器、切换用户、401/403、权限版本变化、mutation 成功、应用版本或 cache schema 变化时清理或精准失效。 +7. 在系统偏好或诊断信息中提供缓存状态和手动清理入口,清理文案不得包含业务详情。 +8. 补充单元测试、运行时边界检查和桌面发布检查,确保 token、密码、下载凭据不会写入缓存,业务模块不会绕过 `frontend/src/runtime/` 使用 Tauri 或底层存储 API。 + +仍禁止把缓存扩展为离线产品能力:不得实现离线登录、离线写入队列、冲突解决、本地审批、本地权限裁决、本地审计回放或本地 API 镜像。服务端不可达时,桌面端不得把缓存解释为已完成身份认证;是否展示已过期缓存必须以后续明确的产品验收口径为准,默认只在在线身份校验通过后使用缓存。 + +如果后续任务试图新增离线登录、离线写入、本地业务队列、离线同步、内嵌后端、本地 API 镜像或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。 ## 当前质量门禁 @@ -199,10 +222,10 @@ npm run desktop:build:app - 先阅读本文档。 - 确认任务属于第一阶段或第二阶段。 -- 确认任务不会引入离线能力。 +- 确认任务不会引入未批准的离线能力;本地缓存任务必须符合 2026-07-08 缓存边界和 `desktop-local-cache-plan.md`。 - 确认实现不会破坏 Web 运行时。 - 确认 Tauri API 使用被隔离在适配层之后,除非有明确记录的理由。 - 确认不会重复初始化 Tauri 或绕过既有 `frontend/src/runtime/` 运行时边界。 -- 涉及 Tauri 权限、CSP、updater、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。 +- 涉及本地缓存、Tauri 权限、CSP、updater、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。 如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。 diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index 2c13eab5..cb6d6f04 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -206,6 +206,7 @@ const verifySourceSafety = async () => { for (const path of files) { const source = await readFile(path, "utf8"); const file = relative(rootDir, path); + const isRuntimeFile = file.startsWith("frontend/src/runtime/"); assert(!/[?&](?:token|access_token)=/i.test(source), `${file}: token must not be passed through query parameters.`); assert( !/console\.(log|debug|info|warn|error)\s*\([^)]*(?:token|access_token|authorization|bearer)/i.test(source), @@ -221,6 +222,12 @@ const verifySourceSafety = async () => { if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") { fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`); } + if (!isRuntimeFile) { + assert( + !/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source), + `${file}: local data cache storage must be routed through frontend/src/runtime.`, + ); + } } }; diff --git a/frontend/scripts/verify-runtime-boundary.mjs b/frontend/scripts/verify-runtime-boundary.mjs index 80dd99e8..08fbc062 100644 --- a/frontend/scripts/verify-runtime-boundary.mjs +++ b/frontend/scripts/verify-runtime-boundary.mjs @@ -31,6 +31,9 @@ for (const path of await walk(sourceDir)) { if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) { violations.push(`${file}: import platform behavior through src/runtime/index.ts`); } + if (/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source)) { + violations.push(`${file}: local data cache storage must be routed through src/runtime`); + } } if (violations.length > 0) { diff --git a/frontend/src/api/auth.test.ts b/frontend/src/api/auth.test.ts new file mode 100644 index 00000000..bcc1f9bd --- /dev/null +++ b/frontend/src/api/auth.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPatch = vi.fn(); +const apiPost = vi.fn(); + +vi.mock("./axios", () => ({ + default: {}, + apiGet, + apiPatch, + apiPost, +})); + +describe("auth api", () => { + it("does not deduplicate login key challenges", async () => { + const { getLoginKey } = await import("./auth"); + + getLoginKey(); + + expect(apiGet).toHaveBeenCalledWith("/api/v1/auth/login-key", { disableRequestDedupe: true }); + }); +}); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index c87e20b7..562a032a 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -22,7 +22,7 @@ export const devLogin = (payload: DevLoginRequest): Promise("/api/v1/auth/dev-login", payload); export const getLoginKey = (): Promise> => - apiGet("/api/v1/auth/login-key"); + apiGet("/api/v1/auth/login-key", { disableRequestDedupe: true }); export const fetchMe = (config?: ApiRequestConfig): Promise> => apiGet("/api/v1/auth/me", config); diff --git a/frontend/src/api/axios.test.ts b/frontend/src/api/axios.test.ts index 417b2060..27e7d72d 100644 --- a/frontend/src/api/axios.test.ts +++ b/frontend/src/api/axios.test.ts @@ -34,10 +34,14 @@ vi.mock("../session/sessionManager", () => ({ describe("api axios retry", () => { beforeEach(() => { vi.useFakeTimers(); + vi.setSystemTime(1_000); errorMessage.mockClear(); }); - afterEach(() => { + afterEach(async () => { + const { clearApiResponseCache, setApiResponseCacheScope } = await import("./axios"); + setApiResponseCacheScope(null); + clearApiResponseCache(); vi.useRealTimers(); }); @@ -67,4 +71,152 @@ describe("api axios retry", () => { await expect(request).resolves.toMatchObject({ message: "Network Error" }); expect(errorMessage).toHaveBeenCalledTimes(1); }); + + it("deduplicates concurrent apiGet calls for the same request", async () => { + const { apiGet } = await import("./axios"); + const adapter = vi.fn(async (config) => ({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config: config as InternalAxiosRequestConfig, + })); + + const [first, second] = await Promise.all([ + apiGet("/api/v1/studies/", { adapter }), + apiGet("/api/v1/studies/", { adapter }), + ]); + + expect(adapter).toHaveBeenCalledTimes(1); + expect(first.data).toEqual({ ok: true }); + expect(second.data).toEqual({ ok: true }); + }); + + it("does not deduplicate requests when disabled by the caller", async () => { + const { apiGet } = await import("./axios"); + const adapter = vi.fn(async (config) => ({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config: config as InternalAxiosRequestConfig, + })); + + await Promise.all([ + apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }), + apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }), + ]); + + expect(adapter).toHaveBeenCalledTimes(2); + }); + + it("keeps request headers in the dedupe key when they can affect the response", async () => { + const { apiGet } = await import("./axios"); + const adapter = vi.fn(async (config) => ({ + data: { accept: (config.headers as any)?.Accept }, + status: 200, + statusText: "OK", + headers: {}, + config: config as InternalAxiosRequestConfig, + })); + + const [json, csv] = await Promise.all([ + apiGet("/api/v1/reports", { adapter, headers: { Accept: "application/json" } }), + apiGet("/api/v1/reports", { adapter, headers: { Accept: "text/csv" } }), + ]); + + expect(adapter).toHaveBeenCalledTimes(2); + expect(json.data).toEqual({ accept: "application/json" }); + expect(csv.data).toEqual({ accept: "text/csv" }); + }); + + it("serves configured apiGet calls from memory cache until ttl expires", async () => { + const { apiGet } = await import("./axios"); + let sequence = 0; + const adapter = vi.fn(async (config) => ({ + data: { sequence: (sequence += 1) }, + status: 200, + statusText: "OK", + headers: { etag: "v1" }, + config: config as InternalAxiosRequestConfig, + })); + + const first = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } }); + const second = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } }); + expect(first.data).toEqual({ sequence: 1 }); + expect(second.data).toEqual({ sequence: 1 }); + expect(adapter).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1000); + const third = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } }); + expect(third.data).toEqual({ sequence: 2 }); + expect(adapter).toHaveBeenCalledTimes(2); + }); + + it("clears cached GET responses after successful mutations", async () => { + const { apiGet, apiPost } = await import("./axios"); + let sequence = 0; + const getAdapter = vi.fn(async (config) => ({ + data: { sequence: (sequence += 1) }, + status: 200, + statusText: "OK", + headers: {}, + config: config as InternalAxiosRequestConfig, + })); + const postAdapter = vi.fn(async (config) => ({ + data: { id: "study-1" }, + status: 200, + statusText: "OK", + headers: {}, + config: config as InternalAxiosRequestConfig, + })); + + await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } }); + await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } }); + expect(getAdapter).toHaveBeenCalledTimes(1); + + await apiPost("/api/v1/studies/", { name: "Study" }, { adapter: postAdapter }); + const afterMutation = await apiGet("/api/v1/studies/", { + adapter: getAdapter, + cache: { ttlMs: 1000, tags: ["studies"] }, + }); + + expect(afterMutation.data).toEqual({ sequence: 2 }); + expect(getAdapter).toHaveBeenCalledTimes(2); + expect(postAdapter).toHaveBeenCalledTimes(1); + }); + + it("does not write an in-flight GET response into cache after the cache generation changes", async () => { + const { apiGet, clearApiResponseCache } = await import("./axios"); + let sequence = 0; + let resolveFirst: (() => void) | undefined; + const adapter = vi.fn((config) => { + sequence += 1; + const response = { + data: { sequence }, + status: 200, + statusText: "OK", + headers: {}, + config: config as InternalAxiosRequestConfig, + }; + if (sequence === 1) { + return new Promise((resolve) => { + resolveFirst = () => resolve(response); + }); + } + return Promise.resolve(response); + }); + + const firstRequest = apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } }); + await vi.dynamicImportSettled(); + expect(adapter).toHaveBeenCalledTimes(1); + + clearApiResponseCache(); + resolveFirst?.(); + await expect(firstRequest).resolves.toMatchObject({ data: { sequence: 1 } }); + + const afterClear = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } }); + expect(afterClear.data).toEqual({ sequence: 2 }); + expect(adapter).toHaveBeenCalledTimes(2); + }); }); diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 336a230e..3f5b7f61 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -14,22 +14,195 @@ export const refreshApiBaseUrl = (): void => { instance.defaults.baseURL = clientRuntime.apiBaseUrl(); }; -if (typeof window !== "undefined") { - window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshApiBaseUrl); -} - const NETWORK_RETRY_LIMIT = 10; const NETWORK_RETRY_DELAY_MS = 30000; +const DEFAULT_GET_CACHE_TTL_MS = 30_000; +const API_RESPONSE_CACHE_SCHEMA_VERSION = 1; export type ApiRequestConfig = AxiosRequestConfig & { suppressErrorMessage?: boolean; _retry?: boolean; _networkRetryCount?: number; disableNetworkRetry?: boolean; + disableRequestDedupe?: boolean; + cache?: boolean | ApiCacheConfig; + invalidateCache?: boolean | { tags?: string[] }; }; +export interface ApiCacheConfig { + ttlMs?: number; + key?: string; + tags?: string[]; +} + +interface CachedAxiosResponse { + data: T; + status: number; + statusText: string; + headers: Record; +} + +const pendingGetRequests = new Map>>(); + const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); +const sensitiveHeaderPattern = /\b(?:authorization|bearer|cookie|token|password|credential|secret)\b/i; + +const stableSerialize = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (value instanceof URLSearchParams) { + return stableSerialize(Object.fromEntries([...value.entries()].sort(([left], [right]) => left.localeCompare(right)))); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(",")}]`; + } + if (typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`) + .join(",")}}`; + } + return JSON.stringify(value); +}; + +const safeClone = (value: T): T => { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +}; + +const rawHeaderEntries = (headers: AxiosRequestConfig["headers"]): Array<[string, unknown]> => { + if (!headers) return []; + if (typeof (headers as any).toJSON === "function") { + return Object.entries((headers as any).toJSON()); + } + if (typeof (headers as any).forEach === "function") { + const entries: Array<[string, unknown]> = []; + (headers as any).forEach((value: unknown, key: string) => entries.push([key, value])); + return entries; + } + return Object.entries(headers as Record); +}; + +const hasSensitiveRequestIdentityConfig = (config?: ApiRequestConfig) => + Boolean(config?.auth) || + rawHeaderEntries(config?.headers).some( + ([key, value]) => sensitiveHeaderPattern.test(key) || sensitiveHeaderPattern.test(String(value)), + ); + +const shouldSkipGetCache = (config?: ApiRequestConfig) => + config?.responseType === "blob" || + config?.responseType === "arraybuffer" || + config?.cache === false || + hasSensitiveRequestIdentityConfig(config); + +const normalizeCacheConfig = (config?: ApiRequestConfig): ApiCacheConfig | null => { + if (!config?.cache || shouldSkipGetCache(config)) return null; + if (config.cache === true) return { ttlMs: DEFAULT_GET_CACHE_TTL_MS }; + return { + ttlMs: config.cache.ttlMs ?? DEFAULT_GET_CACHE_TTL_MS, + key: config.cache.key, + tags: config.cache.tags, + }; +}; + +const createGetCacheKey = (url: string, config?: ApiRequestConfig, explicitKey?: string) => { + if (explicitKey) return explicitKey; + return stableSerialize({ + baseURL: config?.baseURL || instance.defaults.baseURL || "", + headers: Object.fromEntries( + rawHeaderEntries(config?.headers) + .filter(([key, value]) => !sensitiveHeaderPattern.test(key) && !sensitiveHeaderPattern.test(String(value))) + .map(([key, value]) => [key.toLowerCase(), String(value)]) + .sort(([left], [right]) => left.localeCompare(right)), + ), + method: "get", + params: config?.params || null, + paramsSerializer: config?.paramsSerializer ? String(config.paramsSerializer) : null, + responseType: config?.responseType || "json", + schemaVersion: API_RESPONSE_CACHE_SCHEMA_VERSION, + url, + withCredentials: config?.withCredentials || false, + }); +}; + +const createPendingGetKey = (url: string, config?: ApiRequestConfig) => + createGetCacheKey(url, config, typeof config?.cache === "object" ? config.cache.key : undefined); + +const cacheableResponseHeaders = (headers: AxiosResponse["headers"]): Record => { + const rawHeaders = + headers && typeof (headers as any).toJSON === "function" ? (headers as any).toJSON() : (headers as Record); + const allowed = ["cache-control", "etag", "last-modified"]; + return Object.fromEntries( + Object.entries(rawHeaders || {}) + .filter(([key]) => allowed.includes(key.toLowerCase())) + .map(([key, value]) => [key, String(value)]), + ); +}; + +const toCachedResponse = (response: AxiosResponse): CachedAxiosResponse => ({ + data: safeClone(response.data), + status: response.status, + statusText: response.statusText, + headers: cacheableResponseHeaders(response.headers), +}); + +const fromCachedResponse = (cached: CachedAxiosResponse, config?: ApiRequestConfig): AxiosResponse => ({ + data: safeClone(cached.data), + status: cached.status, + statusText: cached.statusText, + headers: cached.headers, + config: (config || {}) as InternalAxiosRequestConfig, +}); + +export const clearApiResponseCache = (): void => { + pendingGetRequests.clear(); + clientRuntime.dataCache.clearDesktopDataCache(); +}; + +export const setApiResponseCacheScope = (scope?: string | null): void => { + pendingGetRequests.clear(); + clientRuntime.dataCache.setDesktopDataCacheScope(scope); +}; + +const requestWithDedupe = (url: string, config?: ApiRequestConfig): Promise> => { + if (config?.disableRequestDedupe || shouldSkipGetCache(config)) { + return instance.get(url, config); + } + const pendingKey = createPendingGetKey(url, config); + const existing = pendingGetRequests.get(pendingKey) as Promise> | undefined; + if (existing) return existing; + const request = instance.get(url, config).finally(() => { + pendingGetRequests.delete(pendingKey); + }); + pendingGetRequests.set(pendingKey, request); + return request; +}; + +const invalidateCacheAfterMutation = async ( + request: Promise>, + config?: ApiRequestConfig, +): Promise> => { + const response = await request; + const invalidation = config?.invalidateCache; + if (invalidation !== false) { + if (typeof invalidation === "object" && invalidation.tags?.length) { + clientRuntime.dataCache.invalidateDesktopDataCacheByTags(invalidation.tags); + } else { + clearApiResponseCache(); + } + } + return response; +}; + +if (typeof window !== "undefined") { + window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, () => { + refreshApiBaseUrl(); + setApiResponseCacheScope(null); + }); +} + const clearInvalidStudyAndNavigate = async () => { try { const [{ useStudyStore }, { default: router }] = await Promise.all([ @@ -88,6 +261,7 @@ instance.interceptors.response.use( } if (status === 401) { const config = error.config as ApiRequestConfig; + let clearedCacheForAuthFailure = false; if (config && !config._retry) { const { extendAccessToken } = await import("../session/sessionManager"); const result = await extendAccessToken("response-401"); @@ -98,12 +272,21 @@ instance.interceptors.response.use( return instance(config); } if (result.authFailed) { + clearApiResponseCache(); + clearedCacheForAuthFailure = true; await forceAuthExpiredLogout(); } } + if (!clearedCacheForAuthFailure) { + clearApiResponseCache(); + } } else if (status === 403 && (data as any)?.detail === "账号已停用") { + clearApiResponseCache(); await forceAuthExpiredLogout(); } else { + if (status === 403) { + clearApiResponseCache(); + } const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage; if (!suppressErrorMessage) { if (data?.message || (data as any)?.detail) { @@ -120,14 +303,37 @@ instance.interceptors.response.use( } ); -export const apiGet = (url: string, config?: ApiRequestConfig) => instance.get(url, config); +export const apiGet = async (url: string, config?: ApiRequestConfig) => { + const cacheConfig = normalizeCacheConfig(config); + const cacheKey = cacheConfig ? createGetCacheKey(url, config, cacheConfig.key) : null; + const cacheGeneration = cacheConfig ? clientRuntime.dataCache.getDesktopDataCacheGeneration() : null; + if (cacheConfig && cacheKey) { + const cached = clientRuntime.dataCache.readDesktopDataCache>(cacheKey); + if (cached) { + return fromCachedResponse(cached, config); + } + } + const response = await requestWithDedupe(url, config); + if ( + cacheConfig && + cacheKey && + cacheGeneration !== null && + clientRuntime.dataCache.isDesktopDataCacheGenerationCurrent(cacheGeneration) + ) { + clientRuntime.dataCache.writeDesktopDataCache(cacheKey, toCachedResponse(response), { + ttlMs: cacheConfig.ttlMs ?? DEFAULT_GET_CACHE_TTL_MS, + tags: cacheConfig.tags, + }); + } + return response; +}; export const apiPost = (url: string, data?: unknown, config?: ApiRequestConfig) => - instance.post(url, data, config); + invalidateCacheAfterMutation(instance.post(url, data, config), config); export const apiPatch = (url: string, data?: unknown, config?: ApiRequestConfig) => - instance.patch(url, data, config); + invalidateCacheAfterMutation(instance.patch(url, data, config), config); export const apiPut = (url: string, data?: unknown, config?: ApiRequestConfig) => - instance.put(url, data, config); + invalidateCacheAfterMutation(instance.put(url, data, config), config); export const apiDelete = (url: string, config?: ApiRequestConfig) => - instance.delete(url, config); + invalidateCacheAfterMutation(instance.delete(url, config), config); export default instance; diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 37384ae4..4a60be98 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -13,16 +13,28 @@ export interface CenterSummaryItem { } export const fetchProgress = (studyId: string) => - apiGet(`/api/v1/studies/${studyId}/dashboard/progress`); + apiGet(`/api/v1/studies/${studyId}/dashboard/progress`, { + cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`] }, + }); export const fetchFinanceSummary = (studyId: string) => - apiGet(`/api/v1/studies/${studyId}/finance/summary`); + apiGet(`/api/v1/studies/${studyId}/finance/summary`, { + cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:finance`] }, + }); export const fetchOverdueAesCount = (studyId: string) => - apiGet>(`/api/v1/studies/${studyId}/aes/`, { params: { overdue: true, limit: 1 } }); + apiGet>(`/api/v1/studies/${studyId}/aes/`, { + params: { overdue: true, limit: 1 }, + cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:aes`] }, + }); export const fetchLostVisits = (studyId: string, params?: Record) => - apiGet(`/api/v1/studies/${studyId}/dashboard/lost-visits`, { params }); + apiGet(`/api/v1/studies/${studyId}/dashboard/lost-visits`, { + params, + cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:visits`] }, + }); export const fetchCenterSummary = (studyId: string) => - apiGet(`/api/v1/studies/${studyId}/dashboard/center-summary`); + apiGet(`/api/v1/studies/${studyId}/dashboard/center-summary`, { + cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:sites`] }, + }); diff --git a/frontend/src/api/members.ts b/frontend/src/api/members.ts index 84027b94..16134f23 100644 --- a/frontend/src/api/members.ts +++ b/frontend/src/api/members.ts @@ -2,10 +2,17 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; import type { StudyMember, UserInfo } from "../types/api"; export const listMembers = (studyId: string, params?: Record) => - apiGet(`/api/v1/studies/${studyId}/members/`, { params }); + apiGet(`/api/v1/studies/${studyId}/members/`, { + params, + cache: { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:members`] }, + }); export const listMemberCandidates = (studyId: string, params?: Record) => - apiGet(`/api/v1/studies/${studyId}/members/candidates`, { params, suppressErrorMessage: true }); + apiGet(`/api/v1/studies/${studyId}/members/candidates`, { + params, + suppressErrorMessage: true, + cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:members`] }, + }); export const addMember = (studyId: string, payload: { user_id: string; role_in_study: string; is_active?: boolean }) => apiPost(`/api/v1/studies/${studyId}/members/`, payload); diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index 870bdbe0..715fb8b6 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -1,3 +1,6 @@ import { apiGet } from "./axios"; -export const fetchProjectOverview = (studyId: string) => apiGet(`/api/v1/studies/${studyId}/overview`); +export const fetchProjectOverview = (studyId: string) => + apiGet(`/api/v1/studies/${studyId}/overview`, { + cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:overview`] }, + }); diff --git a/frontend/src/api/projectPermissions.ts b/frontend/src/api/projectPermissions.ts index 8d07cf48..d30a07d4 100644 --- a/frontend/src/api/projectPermissions.ts +++ b/frontend/src/api/projectPermissions.ts @@ -17,10 +17,16 @@ import type { // 接口级权限 export const fetchApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) => - apiGet(`/api/v1/studies/${studyId}/api-permissions`, config); + apiGet(`/api/v1/studies/${studyId}/api-permissions`, { + ...(config || {}), + cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] }, + }); export const fetchMyApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) => - apiGet(`/api/v1/studies/${studyId}/api-permissions/me`, config); + apiGet(`/api/v1/studies/${studyId}/api-permissions/me`, { + ...(config || {}), + cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] }, + }); export const updateApiEndpointPermissions = ( studyId: string, @@ -32,14 +38,20 @@ export const updateApiEndpointPermissions = ( }); export const fetchApiOperations = () => - apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`); + apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`, { + cache: { ttlMs: 24 * 60 * 60_000, tags: ["permission-operations"] }, + }); // 系统监测API export const fetchPermissionMetrics = () => - apiGet(`/api/v1/permission-monitoring/metrics`); + apiGet(`/api/v1/permission-monitoring/metrics`, { + cache: { ttlMs: 30_000, tags: ["permission-monitoring"] }, + }); export const fetchCacheStats = () => - apiGet(`/api/v1/permission-monitoring/cache-stats`); + apiGet(`/api/v1/permission-monitoring/cache-stats`, { + cache: { ttlMs: 30_000, tags: ["permission-monitoring"] }, + }); export const fetchPermissionAlerts = (level?: string, limit?: number) => apiGet(`/api/v1/permission-monitoring/alerts`, { @@ -47,7 +59,9 @@ export const fetchPermissionAlerts = (level?: string, limit?: number) => }); export const fetchPermissionHealth = () => - apiGet(`/api/v1/permission-monitoring/health`); + apiGet(`/api/v1/permission-monitoring/health`, { + cache: { ttlMs: 30_000, tags: ["permission-monitoring"] }, + }); export const resetPermissionMetrics = () => apiPost(`/api/v1/permission-monitoring/reset-metrics`); @@ -87,7 +101,9 @@ export const fetchIpLocations = (params?: { days?: number; limit?: number }) => apiGet(`/api/v1/permission-monitoring/ip-locations`, { params }); export const fetchStatsSummary = () => - apiGet(`/api/v1/permission-monitoring/stats-summary`); + apiGet(`/api/v1/permission-monitoring/stats-summary`, { + cache: { ttlMs: 30_000, tags: ["permission-monitoring"] }, + }); // 权限模板API export interface PermissionTemplate { @@ -129,10 +145,15 @@ export interface ApplyTemplateResponse { } export const fetchPermissionTemplates = (params?: { template_type?: string; category?: string }) => - apiGet(`/api/v1/permission-templates`, { params }); + apiGet(`/api/v1/permission-templates`, { + params, + cache: { ttlMs: 5 * 60_000, tags: ["permission-templates"] }, + }); export const fetchPermissionTemplate = (templateId: string) => - apiGet(`/api/v1/permission-templates/${templateId}`); + apiGet(`/api/v1/permission-templates/${templateId}`, { + cache: { ttlMs: 5 * 60_000, tags: ["permission-templates", `permission-template:${templateId}`] }, + }); export const createPermissionTemplate = (payload: PermissionTemplateCreate) => apiPost(`/api/v1/permission-templates`, payload); @@ -165,11 +186,15 @@ export interface SystemPermissionsResponse { } export const fetchSystemPermissions = () => - apiGet(`/api/v1/system-permissions`); + apiGet(`/api/v1/system-permissions`, { + cache: { ttlMs: 24 * 60 * 60_000, tags: ["system-permissions"] }, + }); // 项目角色生效管理 export const fetchActiveRoles = (studyId: string) => - apiGet<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`); + apiGet<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`, { + cache: { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] }, + }); export const updateActiveRoles = (studyId: string, activeRoles: string[]) => apiPut<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`, { active_roles: activeRoles }); diff --git a/frontend/src/api/sites.ts b/frontend/src/api/sites.ts index 9f838b56..5a64e6f9 100644 --- a/frontend/src/api/sites.ts +++ b/frontend/src/api/sites.ts @@ -5,6 +5,7 @@ export const fetchSites = (studyId: string, params?: Record, config apiGet | Site[]>(`/api/v1/studies/${studyId}/sites/`, { ...(config || {}), params: { include_inactive: true, ...(params || {}), ...(config?.params || {}) }, + cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:sites`] }, }); export const createSite = (studyId: string, payload: Partial & { name: string }) => diff --git a/frontend/src/api/studies.ts b/frontend/src/api/studies.ts index e1ef8b48..c93732cd 100644 --- a/frontend/src/api/studies.ts +++ b/frontend/src/api/studies.ts @@ -12,7 +12,8 @@ import type { } from "../types/setupConfig"; // Backend expects trailing slash to avoid 307 redirect -export const fetchStudies = () => apiGet>("/api/v1/studies/"); +export const fetchStudies = () => + apiGet>("/api/v1/studies/", { cache: { ttlMs: 5 * 60_000, tags: ["studies"] } }); export const createStudy = (payload: Partial & { name: string }) => apiPost("/api/v1/studies/", payload); @@ -20,7 +21,8 @@ export const createStudy = (payload: Partial & { name: string }) => export const updateStudy = (studyId: string, payload: Partial) => apiPatch(`/api/v1/studies/${studyId}`, payload); -export const fetchStudyDetail = (studyId: string) => apiGet(`/api/v1/studies/${studyId}`); +export const fetchStudyDetail = (studyId: string) => + apiGet(`/api/v1/studies/${studyId}`, { cache: { ttlMs: 5 * 60_000, tags: ["studies", `study:${studyId}`] } }); export const deleteStudy = (studyId: string) => apiDelete(`/api/v1/studies/${studyId}`); @@ -29,7 +31,10 @@ export const lockStudy = (studyId: string) => apiPatch(`/api/v1/studies/${studyId}/lock`, {}); export const fetchStudySetupConfig = (studyId: string) => - apiGet(`/api/v1/studies/${studyId}/setup-config`, { suppressErrorMessage: true }); + apiGet(`/api/v1/studies/${studyId}/setup-config`, { + suppressErrorMessage: true, + cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:setup-config`] }, + }); export const saveStudySetupConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => apiPut(`/api/v1/studies/${studyId}/setup-config`, payload, { suppressErrorMessage: true }); @@ -38,7 +43,10 @@ export const publishStudySetupConfig = (studyId: string, payload: StudySetupConf apiPost(`/api/v1/studies/${studyId}/setup-config/publish`, payload, { suppressErrorMessage: true }); export const fetchStudySetupConfigVersions = (studyId: string) => - apiGet(`/api/v1/studies/${studyId}/setup-config/versions`, { suppressErrorMessage: true }); + apiGet(`/api/v1/studies/${studyId}/setup-config/versions`, { + suppressErrorMessage: true, + cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:setup-config`] }, + }); export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: number) => apiDelete(`/api/v1/studies/${studyId}/setup-config/versions/${targetVersion}`, { suppressErrorMessage: true }); diff --git a/frontend/src/runtime/clientRuntime.ts b/frontend/src/runtime/clientRuntime.ts index 2b8bdf33..bc6dc4a7 100644 --- a/frontend/src/runtime/clientRuntime.ts +++ b/frontend/src/runtime/clientRuntime.ts @@ -1,5 +1,6 @@ import { resolveApiBaseUrl } from "./apiBaseUrl"; import { getAppMetadata } from "./appMetadata"; +import * as dataCache from "./desktopDataCache"; import * as files from "./files"; import * as notifications from "./notifications"; import * as updates from "./updates"; @@ -33,6 +34,7 @@ export interface ClientRuntime { files: typeof files; notifications: typeof notifications; updates: typeof updates; + dataCache: typeof dataCache; } export const clientRuntime: ClientRuntime = { @@ -47,6 +49,7 @@ export const clientRuntime: ClientRuntime = { files, notifications, updates, + dataCache, capabilities: () => ({ serverConfiguration: isTauriRuntime(), nativeFiles: isTauriRuntime(), diff --git a/frontend/src/runtime/desktopDataCache.test.ts b/frontend/src/runtime/desktopDataCache.test.ts new file mode 100644 index 00000000..413c7ffc --- /dev/null +++ b/frontend/src/runtime/desktopDataCache.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearDesktopDataCache, + getDesktopDataCacheGeneration, + getDesktopDataCacheStats, + invalidateDesktopDataCacheByTags, + isDesktopDataCacheGenerationCurrent, + readDesktopDataCache, + setDesktopDataCacheScope, + writeDesktopDataCache, +} from "./desktopDataCache"; + +describe("desktopDataCache", () => { + afterEach(() => { + setDesktopDataCacheScope(null); + clearDesktopDataCache(); + }); + + it("isolates records by scope and clears records when scope changes", () => { + setDesktopDataCacheScope("user-a"); + writeDesktopDataCache("studies", { items: [{ id: "study-a" }] }, { ttlMs: 1000, now: 100 }); + + expect(readDesktopDataCache("studies", 200)).toEqual({ items: [{ id: "study-a" }] }); + + setDesktopDataCacheScope("user-b"); + expect(readDesktopDataCache("studies", 200)).toBeNull(); + expect(getDesktopDataCacheStats()).toMatchObject({ scope: "user-b", entries: 0 }); + }); + + it("expires records by ttl", () => { + writeDesktopDataCache("overview", { total: 3 }, { ttlMs: 100, now: 1000 }); + + expect(readDesktopDataCache("overview", 1099)).toEqual({ total: 3 }); + expect(readDesktopDataCache("overview", 1100)).toBeNull(); + }); + + it("invalidates records by tag", () => { + writeDesktopDataCache("sites", { items: [] }, { ttlMs: 1000, tags: ["study:1:sites"], now: 100 }); + writeDesktopDataCache("members", { items: [] }, { ttlMs: 1000, tags: ["study:1:members"], now: 100 }); + + invalidateDesktopDataCacheByTags(["study:1:sites"]); + + expect(readDesktopDataCache("sites", 200)).toBeNull(); + expect(readDesktopDataCache("members", 200)).toEqual({ items: [] }); + }); + + it("bumps generation when records are invalidated", () => { + const initialGeneration = getDesktopDataCacheGeneration(); + writeDesktopDataCache("sites", { items: [] }, { ttlMs: 1000, tags: ["study:1:sites"], now: 100 }); + + invalidateDesktopDataCacheByTags(["study:1:sites"]); + + expect(isDesktopDataCacheGenerationCurrent(initialGeneration)).toBe(false); + const afterTagInvalidation = getDesktopDataCacheGeneration(); + + clearDesktopDataCache(); + expect(isDesktopDataCacheGenerationCurrent(afterTagInvalidation)).toBe(false); + }); +}); diff --git a/frontend/src/runtime/desktopDataCache.ts b/frontend/src/runtime/desktopDataCache.ts new file mode 100644 index 00000000..ca83751b --- /dev/null +++ b/frontend/src/runtime/desktopDataCache.ts @@ -0,0 +1,140 @@ +export interface DesktopDataCacheWriteOptions { + ttlMs: number; + tags?: string[]; + now?: number; +} + +export interface DesktopDataCacheStats { + scope: string; + entries: number; + estimatedBytes: number; + oldestUpdatedAt: number | null; + newestUpdatedAt: number | null; +} + +interface DesktopDataCacheRecord { + value: unknown; + expiresAt: number; + createdAt: number; + updatedAt: number; + lastAccessedAt: number; + tags: string[]; + estimatedBytes: number; +} + +const DEFAULT_SCOPE = "anonymous"; +const MAX_MEMORY_CACHE_ENTRIES = 300; + +const records = new Map(); +let currentScope = DEFAULT_SCOPE; +let cacheGeneration = 0; + +const normalizeScope = (scope?: string | null) => { + const normalized = String(scope || "").trim(); + return normalized || DEFAULT_SCOPE; +}; + +const scopedKey = (key: string) => `${currentScope}:${key}`; + +const cloneValue = (value: T): T => { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +}; + +const estimateBytes = (value: unknown) => { + try { + return new Blob([JSON.stringify(value)]).size; + } catch { + return 0; + } +}; + +const pruneExpired = (now: number) => { + for (const [key, record] of records.entries()) { + if (record.expiresAt <= now) { + records.delete(key); + } + } +}; + +const enforceEntryLimit = () => { + if (records.size <= MAX_MEMORY_CACHE_ENTRIES) return; + const sorted = [...records.entries()].sort(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt); + const removeCount = records.size - MAX_MEMORY_CACHE_ENTRIES; + sorted.slice(0, removeCount).forEach(([key]) => records.delete(key)); +}; + +export const setDesktopDataCacheScope = (scope?: string | null): void => { + const nextScope = normalizeScope(scope); + if (nextScope === currentScope) return; + records.clear(); + currentScope = nextScope; + cacheGeneration += 1; +}; + +export const getDesktopDataCacheScope = (): string => currentScope; + +export const getDesktopDataCacheGeneration = (): number => cacheGeneration; + +export const isDesktopDataCacheGenerationCurrent = (generation: number): boolean => generation === cacheGeneration; + +export const readDesktopDataCache = (key: string, now: number = Date.now()): T | null => { + const recordKey = scopedKey(key); + const record = records.get(recordKey); + if (!record) return null; + if (record.expiresAt <= now) { + records.delete(recordKey); + return null; + } + record.lastAccessedAt = now; + return cloneValue(record.value as T); +}; + +export const writeDesktopDataCache = ( + key: string, + value: T, + options: DesktopDataCacheWriteOptions, +): void => { + if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) return; + const now = options.now ?? Date.now(); + pruneExpired(now); + records.set(scopedKey(key), { + value: cloneValue(value), + expiresAt: now + options.ttlMs, + createdAt: now, + updatedAt: now, + lastAccessedAt: now, + tags: [...(options.tags || [])], + estimatedBytes: estimateBytes(value), + }); + enforceEntryLimit(); +}; + +export const invalidateDesktopDataCacheByTags = (tags: string[]): void => { + if (!tags.length) return; + const tagSet = new Set(tags); + for (const [key, record] of records.entries()) { + if (record.tags.some((tag) => tagSet.has(tag))) { + records.delete(key); + } + } + cacheGeneration += 1; +}; + +export const clearDesktopDataCache = (): void => { + records.clear(); + cacheGeneration += 1; +}; + +export const getDesktopDataCacheStats = (): DesktopDataCacheStats => { + const updatedAtValues = [...records.values()].map((record) => record.updatedAt); + return { + scope: currentScope, + entries: records.size, + estimatedBytes: [...records.values()].reduce((sum, record) => sum + record.estimatedBytes, 0), + oldestUpdatedAt: updatedAtValues.length ? Math.min(...updatedAtValues) : null, + newestUpdatedAt: updatedAtValues.length ? Math.max(...updatedAtValues) : null, + }; +}; diff --git a/frontend/src/runtime/index.ts b/frontend/src/runtime/index.ts index 3b9cf343..90bdb10a 100644 --- a/frontend/src/runtime/index.ts +++ b/frontend/src/runtime/index.ts @@ -27,6 +27,19 @@ export { listenDesktopMenuCommand, type DesktopMenuCommand, } from "./desktopMenu"; +export { + clearDesktopDataCache, + getDesktopDataCacheGeneration, + getDesktopDataCacheScope, + getDesktopDataCacheStats, + invalidateDesktopDataCacheByTags, + isDesktopDataCacheGenerationCurrent, + readDesktopDataCache, + setDesktopDataCacheScope, + writeDesktopDataCache, + type DesktopDataCacheStats, + type DesktopDataCacheWriteOptions, +} from "./desktopDataCache"; export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata"; export { cleanupTemporaryFiles, diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index c4e8d35a..cd69a7c6 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia"; import { ref } from "vue"; import { getLoginKey, login as apiLogin, devLogin, fetchMe } from "../api/auth"; -import type { ApiRequestConfig } from "../api/axios"; +import { clearApiResponseCache, setApiResponseCacheScope, type ApiRequestConfig } from "../api/axios"; import { setToken, clearToken, getToken } from "../utils/auth"; import { encryptLoginPayload } from "../utils/loginCrypto"; import type { UserInfo } from "../types/api"; @@ -21,6 +21,7 @@ export const useAuthStore = defineStore("auth", () => { const login = async (email: string, password: string, options: { restoreStudy?: boolean } = {}) => { loading.value = true; try { + clearApiResponseCache(); const { data } = allowInsecureDevLogin && !window.isSecureContext ? await devLogin({ email, password }) @@ -59,6 +60,7 @@ export const useAuthStore = defineStore("auth", () => { const fetchMeAction = async (config?: ApiRequestConfig) => { const { data } = await fetchMe(config); user.value = data; + setApiResponseCacheScope(data?.id || data?.email || null); if (data?.email) { localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email); } @@ -75,6 +77,7 @@ export const useAuthStore = defineStore("auth", () => { token.value = null; user.value = null; forceLogin.value = false; + setApiResponseCacheScope(null); sessionStore.resetActivity(); await clearToken(); studyStore.clearCurrentStudy();