19 Commits

Author SHA1 Message Date
Cheng Zhou 72b907947b release(main): promote v0.1.0 candidate
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Desktop Release Candidate / Resolve desktop release signing policy (push) Has been cancelled
Desktop Release Candidate / macOS release candidate (push) Has been cancelled
Desktop Release Candidate / Windows release candidate (push) Has been cancelled
Desktop Release Candidate / Verify combined desktop release directory (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-17 11:34:26 +08:00
Cheng Zhou e3489f3b06 build(release): stage local macOS v0.1.0 distribution
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-17 11:31:11 +08:00
chengchengzhou7 403776cc1c release(main): 同步 v0.1.0 updater 信任根 (#9)
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
* docs: add desktop project plan

* feat(desktop): implement phase 1 tauri client

* refactor(client): unify web and desktop release workflow

* feat(desktop): implement phase 2 native capabilities

* 完善桌面端交互体验与发布检查

* 完善桌面端界面、发布检查与邮箱域名同步

* fix(deploy): 修复数据库初始化复用旧镜像

* fix(deploy): 增加部署更新实时进度

* fix(auth): 支持无邮箱后缀时手动输入

* feat(desktop): 稳定桌面端界面与文件操作反馈

- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试

* feat(desktop): 支持桌面端三十天免登录

* 完善桌面端发布稳定化门禁

* 完善桌面端端到端回归收口

* 补齐桌面端附件文件流回归

* 完善桌面端回归与安全边界复审

* 完善桌面体验与系统通知收口

* feat(desktop): 收口桌面工作台视觉与活动反馈

* 优化桌面端界面布局

* style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标

* 功能(桌面端):增加在线辅助本地缓存

* 优化桌面端标签导航与后台交互

* ci: 新增 Windows 桌面端内测构建

* fix: 修复桌面检查脚本的 Windows 路径判断

* test: 兼容 Windows 换行的桌面布局断言

* test: 兼容 Windows 换行的路由断言

* ci: 修复 Windows 内测构建配置传参

* ci: 避免 Windows 安装器构建交互等待

* 修复桌面端界面显示与稳定性问题

* feat(网页端): 完善登录后工作台与项目管理体验

* docs(desktop): 精简桌面端约束入口

* feat(admin): 完善审计访问上下文与后台布局

* fix(git): 跟踪原生图标资源

* fix(web): 修正工作台端侧标识

* feat(监控): 完善系统监控、登录状态与访问审计能力

* 修复(权限管理):统一 PM 系统导航与权限校验

* feat(工作台): 优化入口布局与连接安全状态

* feat(桌面与监控): 完善工作台导航和登录活动定位

- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖

* 功能(文档与桌面):完善文件预览下载与客户端构建基线

- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档

* 功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验

新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。

* feat(collaboration): 完善在线文档协作与通知闭环

- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。

* refactor(frontend): 按需加载页面并清理未使用代码

- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

- 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。

- 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。

- 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。

- 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。

- 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。

- 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。

- 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。

- 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。

- 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。

* fix(审计): 移除共享库审计与预览噪声

* 功能(提醒):统一项目提醒中心与桌面通知链路

增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。

* feat(deploy): 默认安装 ONLYOFFICE 标准组件

* build(release): 加固 v0.1.0 桌面发布链路 (#3)

* build(release): 轮换 v0.1.0 updater 公钥 (#7)
2026-07-17 10:58:22 +08:00
Cheng Zhou c23a5d6a22 build(desktop): allow controlled unsigned v0.1.0 release
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-07-17 10:52:20 +08:00
chengchengzhou7 fa26c84bd2 Merge pull request #8 from chengchengzhou7/codex/reconcile-main-updater-key
chore(branch): 回接 v0.1.0 main 候选历史
2026-07-17 10:11:59 +08:00
Cheng Zhou c5c9bb5527 chore(branch): 回接 v0.1.0 main 候选历史 2026-07-17 10:10:48 +08:00
chengchengzhou7 feb1f3db65 build(release): 轮换 v0.1.0 updater 公钥 (#7)
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-17 10:09:16 +08:00
chengchengzhou7 de3dc87920 release(main): 准备 v0.1.0 候选版本 (#5)
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
* docs: add desktop project plan

* feat(desktop): implement phase 1 tauri client

* refactor(client): unify web and desktop release workflow

* feat(desktop): implement phase 2 native capabilities

* 完善桌面端交互体验与发布检查

* 完善桌面端界面、发布检查与邮箱域名同步

* fix(deploy): 修复数据库初始化复用旧镜像

* fix(deploy): 增加部署更新实时进度

* fix(auth): 支持无邮箱后缀时手动输入

* feat(desktop): 稳定桌面端界面与文件操作反馈

- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试

* feat(desktop): 支持桌面端三十天免登录

* 完善桌面端发布稳定化门禁

* 完善桌面端端到端回归收口

* 补齐桌面端附件文件流回归

* 完善桌面端回归与安全边界复审

* 完善桌面体验与系统通知收口

* feat(desktop): 收口桌面工作台视觉与活动反馈

* 优化桌面端界面布局

* style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标

* 功能(桌面端):增加在线辅助本地缓存

* 优化桌面端标签导航与后台交互

* ci: 新增 Windows 桌面端内测构建

* fix: 修复桌面检查脚本的 Windows 路径判断

* test: 兼容 Windows 换行的桌面布局断言

* test: 兼容 Windows 换行的路由断言

* ci: 修复 Windows 内测构建配置传参

* ci: 避免 Windows 安装器构建交互等待

* 修复桌面端界面显示与稳定性问题

* feat(网页端): 完善登录后工作台与项目管理体验

* docs(desktop): 精简桌面端约束入口

* feat(admin): 完善审计访问上下文与后台布局

* fix(git): 跟踪原生图标资源

* fix(web): 修正工作台端侧标识

* feat(监控): 完善系统监控、登录状态与访问审计能力

* 修复(权限管理):统一 PM 系统导航与权限校验

* feat(工作台): 优化入口布局与连接安全状态

* feat(桌面与监控): 完善工作台导航和登录活动定位

- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖

* 功能(文档与桌面):完善文件预览下载与客户端构建基线

- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档

* 功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验

新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。

* feat(collaboration): 完善在线文档协作与通知闭环

- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。

* refactor(frontend): 按需加载页面并清理未使用代码

- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

- 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。

- 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。

- 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。

- 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。

- 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。

- 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。

- 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。

- 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。

- 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。

* fix(审计): 移除共享库审计与预览噪声

* 功能(提醒):统一项目提醒中心与桌面通知链路

增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。

* feat(deploy): 默认安装 ONLYOFFICE 标准组件

* build(release): 加固 v0.1.0 桌面发布链路 (#3)
2026-07-17 09:47:40 +08:00
chengchengzhou7 a0aa9384d7 Merge pull request #4 from chengchengzhou7/codex/reconcile-main-v0-1-0
chore(branch): 对齐 v0.1.0 发布分支历史
2026-07-17 09:41:32 +08:00
Cheng Zhou a938d2ed5f chore(branch): 对齐 v0.1.0 发布分支历史 2026-07-17 09:39:42 +08:00
chengchengzhou7 4863ade45b build(release): 加固 v0.1.0 桌面发布链路 (#3)
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-17 09:36:16 +08:00
Cheng Zhou d5279b124f release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-07-16 17:15:50 +08:00
Cheng Zhou 1837ceff58 feat(deploy): 默认安装 ONLYOFFICE 标准组件 2026-07-16 17:09:12 +08:00
Cheng Zhou 3e77127687 功能(提醒):统一项目提醒中心与桌面通知链路
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。
2026-07-16 16:50:34 +08:00
Cheng Zhou 88bd0c5942 fix(审计): 移除共享库审计与预览噪声 2026-07-16 15:29:26 +08:00
Cheng Zhou 32167fba02 release(main): 同步登录邮箱后缀兼容修复
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-07-01 14:40:52 +08:00
Cheng Zhou 18a3166463 release(main): 同步部署更新实时进度
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-07-01 11:23:22 +08:00
Cheng Zhou 2d7d13b2f5 release(main): 同步数据库初始化镜像修复
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-07-01 11:16:54 +08:00
Cheng Zhou b491b6a146 发布候选:整合桌面端界面与发布稳定化里程碑
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-07-01 10:53:24 +08:00
97 changed files with 4842 additions and 1019 deletions
@@ -40,6 +40,8 @@ jobs:
web:
name: Shared client and Web
runs-on: ubuntu-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults:
run:
working-directory: frontend
@@ -104,6 +106,8 @@ jobs:
desktop:
name: macOS Desktop
runs-on: macos-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults:
run:
working-directory: frontend
+343 -21
View File
@@ -14,9 +14,40 @@ on:
permissions:
contents: read
concurrency:
group: desktop-release-candidate-${{ github.ref }}
cancel-in-progress: false
jobs:
release-policy:
name: Resolve desktop release signing policy
runs-on: ubuntu-latest
outputs:
platform_signing_mode: ${{ steps.policy.outputs.platform_signing_mode }}
macos_signing: ${{ steps.policy.outputs.macos_signing }}
windows_signing: ${{ steps.policy.outputs.windows_signing }}
artifact_label: ${{ steps.policy.outputs.artifact_label }}
warning_required: ${{ steps.policy.outputs.warning_required }}
defaults:
run:
working-directory: frontend
steps:
- name: Checkout release source
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
- name: Resolve version-scoped signing policy
id: policy
shell: bash
run: npm run desktop:release-policy:check -- --require-tag --github-output "${GITHUB_OUTPUT}"
macos-release-candidate:
name: Signed macOS release candidate
name: macOS release candidate
needs: release-policy
runs-on: macos-latest
defaults:
run:
@@ -24,8 +55,13 @@ jobs:
env:
VITE_BUILD_CHANNEL: release
VITE_BUILD_COMMIT: ${{ github.sha }}
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
RELEASE_BUILD: "true"
REQUIRE_DESKTOP_SIGNING: "true"
DESKTOP_RELEASE_PLATFORM: macos
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
REQUIRE_UPDATER_SIGNING: "true"
REQUIRE_DESKTOP_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -50,7 +86,7 @@ jobs:
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
echo "Signed desktop release candidates must run from a vX.Y.Z tag."
echo "Desktop release candidates must run from a vX.Y.Z tag."
exit 1
fi
expected_tag="v$(node -p "require('./package.json').version")"
@@ -67,10 +103,13 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Audit dependencies
run: npm audit
- name: Check release build metadata and signing environment
run: npm run release:env:check
- name: Check signed desktop release readiness
- name: Check desktop release readiness
run: npm run desktop:release-readiness:check
- name: Check synchronized client version
@@ -94,41 +133,324 @@ jobs:
- name: Build Web artifact
run: npm run build
- name: Build signed Universal macOS artifacts
- name: Build Apple-signed and notarized Universal macOS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
run: npm run desktop:build:macos-release -- --ci
- name: Create desktop update feed
- name: Build ad-hoc Universal macOS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
run: npm run desktop:build:macos-unsigned-release -- --ci
- name: Verify and stage macOS artifacts
shell: bash
run: |
if [[ -z "${DESKTOP_UPDATE_BASE_URL}" ]]; then
echo "DESKTOP_UPDATE_BASE_URL or workflow input artifact_base_url is required."
exit 1
fi
app="$(find src-tauri/target -path '*/release/bundle/macos/*.app' -print -quit)"
artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)"
if [[ -z "${artifact}" ]]; then
echo "No macOS updater artifact was produced."
dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then
echo "macOS app, updater artifact/signature, and DMG are all required."
exit 1
fi
include_args=()
dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
if [[ -n "${dmg}" ]]; then
include_args+=(--include "${dmg}")
codesign --verify --deep --strict --verbose=2 "${app}"
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "signed" ]]; then
spctl --assess --type execute --verbose=2 "${app}"
xcrun stapler validate "${app}"
xcrun stapler validate "${dmg}"
else
signature_info="$(codesign -dv --verbose=4 "${app}" 2>&1)"
if ! grep -q "Signature=adhoc" <<<"${signature_info}"; then
echo "The macOS unsigned-platform exception must produce an ad-hoc signed app."
exit 1
fi
fi
stage="src-tauri/target/desktop-release-macos"
mkdir -p "${stage}"
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "unsigned-exception" ]]; then
artifact_name="$(basename "${artifact}" .app.tar.gz)_UNSIGNED.app.tar.gz"
dmg_name="$(basename "${dmg}" .dmg)_UNSIGNED.dmg"
cp "${artifact}" "${stage}/${artifact_name}"
cp "${artifact}.sig" "${stage}/${artifact_name}.sig"
cp "${dmg}" "${stage}/${dmg_name}"
cp desktop-release-unsigned-warning.txt "${stage}/UNSIGNED-PLATFORM-RELEASE.txt"
else
cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/"
fi
- name: Upload macOS candidate artifacts
uses: actions/upload-artifact@v4
with:
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-macos/*
if-no-files-found: error
windows-release-candidate:
name: Windows release candidate
needs: release-policy
runs-on: windows-latest
defaults:
run:
working-directory: frontend
env:
VITE_BUILD_CHANNEL: release
VITE_BUILD_COMMIT: ${{ github.sha }}
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
RELEASE_BUILD: "true"
DESKTOP_RELEASE_PLATFORM: windows
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
REQUIRE_UPDATER_SIGNING: "true"
REQUIRE_WINDOWS_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }}
steps:
- name: Checkout release source
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Enforce release tag context
shell: pwsh
run: |
if ($env:GITHUB_REF_TYPE -ne "tag") {
throw "Desktop release candidates must run from a vX.Y.Z tag."
}
$expectedTag = "v$(node -p "require('./package.json').version")"
if ($env:GITHUB_REF_NAME -ne $expectedTag) {
throw "Release tag $env:GITHUB_REF_NAME does not match package version $expectedTag."
}
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: npm ci
- name: Audit dependencies
run: npm audit
- name: Check release build metadata and signing environment
run: npm run release:env:check
- name: Check desktop release readiness
run: npm run desktop:release-readiness:check
- name: Check synchronized client version
run: npm run version:check
- name: Check runtime boundary
run: npm run runtime:check
- name: Check desktop release and security gate
run: npm run desktop:release:check
- name: Check UI contract
run: npm run ui:contract
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Build Web artifact
run: npm run build
- name: Import Windows code-signing certificate
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
shell: pwsh
run: |
$pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx"
$encodedCertificate = $env:WINDOWS_CERTIFICATE `
-replace "-----BEGIN [^-]+-----", "" `
-replace "-----END [^-]+-----", "" `
-replace "\s", ""
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($encodedCertificate))
$password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
$importedCertificates = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\CurrentUser\My" -Password $password -Exportable:$false
$certificate = $importedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1
if (-not $certificate -or -not $certificate.HasPrivateKey) {
throw "The imported Windows code-signing certificate is missing its private key."
}
$now = Get-Date
if ($certificate.NotBefore -gt $now -or $certificate.NotAfter -le $now) {
throw "The Windows code-signing certificate is not currently valid."
}
$codeSigningEku = $certificate.EnhancedKeyUsageList | Where-Object { $_.ObjectId.Value -eq "1.3.6.1.5.5.7.3.3" }
if (-not $codeSigningEku) {
throw "The Windows certificate does not contain the Code Signing enhanced key usage."
}
"WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
Remove-Item $pfxPath -Force
- name: Write signed Windows Tauri config
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
shell: pwsh
run: |
$config = @{
bundle = @{
windows = @{
certificateThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT
digestAlgorithm = "sha256"
timestampUrl = $env:WINDOWS_TIMESTAMP_URL
tsp = $true
}
}
} | ConvertTo-Json -Depth 8
Set-Content -Path "tauri.windows.release.conf.json" -Value $config -Encoding utf8
Get-Content "tauri.windows.release.conf.json"
- name: Build signed Windows NSIS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
timeout-minutes: 30
run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci
- name: Build unsigned Windows NSIS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
timeout-minutes: 30
run: npm run desktop:build:windows-release -- --ci
- name: Verify platform signing mode and stage Windows artifacts
shell: pwsh
run: |
$bundleDir = "src-tauri/target/release/bundle/nsis"
$installers = @(Get-ChildItem -Path $bundleDir -Filter "*.exe" -File)
$updaters = @(Get-ChildItem -Path $bundleDir -Filter "*.nsis.zip" -File)
$appExecutables = @(Get-ChildItem -Path "src-tauri/target/release" -Filter "*.exe" -File)
if ($installers.Count -ne 1 -or $updaters.Count -ne 1 -or $appExecutables.Count -eq 0) {
throw "Exactly one NSIS installer/updater and at least one application executable are required."
}
$signaturePath = "$($updaters[0].FullName).sig"
if (-not (Test-Path $signaturePath) -or (Get-Item $signaturePath).Length -eq 0) {
throw "The Windows updater signature is missing or empty."
}
foreach ($executable in @($appExecutables + $installers)) {
$signature = Get-AuthenticodeSignature -FilePath $executable.FullName
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "signed") {
if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) {
throw "Authenticode signature or RFC 3161 timestamp is invalid for $($executable.FullName): $($signature.StatusMessage)"
}
} elseif ($signature.Status -ne "NotSigned") {
throw "The Windows unsigned-platform exception must produce an Authenticode-unsigned executable: $($executable.FullName) returned $($signature.Status)."
}
}
$stage = "src-tauri/target/desktop-release-windows"
New-Item -ItemType Directory -Path $stage -Force | Out-Null
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "unsigned-exception") {
$installerName = $installers[0].Name -replace '\.exe$', '_UNSIGNED.exe'
$updaterName = $updaters[0].Name -replace '\.nsis\.zip$', '_UNSIGNED.nsis.zip'
Copy-Item $installers[0].FullName -Destination (Join-Path $stage $installerName)
Copy-Item $updaters[0].FullName -Destination (Join-Path $stage $updaterName)
Copy-Item $signaturePath -Destination (Join-Path $stage "$updaterName.sig")
Copy-Item "desktop-release-unsigned-warning.txt" -Destination (Join-Path $stage "UNSIGNED-PLATFORM-RELEASE.txt")
} else {
Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage
}
- name: Remove Windows signing certificate
if: always()
shell: pwsh
run: |
if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) {
Remove-Item "Cert:\CurrentUser\My\$env:WINDOWS_CERTIFICATE_THUMBPRINT" -Force -ErrorAction SilentlyContinue
}
Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue
Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue
- name: Upload Windows candidate artifacts
uses: actions/upload-artifact@v4
with:
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-windows/*
if-no-files-found: error
aggregate-release-candidate:
name: Verify combined desktop release directory
needs:
- release-policy
- macos-release-candidate
- windows-release-candidate
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
env:
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
steps:
- name: Checkout release source
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
- name: Download macOS candidate artifacts
uses: actions/download-artifact@v4
with:
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-input/macos
- name: Download Windows candidate artifacts
uses: actions/download-artifact@v4
with:
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-input/windows
- name: Create desktop release provenance
run: npm run desktop:release-provenance:create -- --macos-signing "${{ needs.release-policy.outputs.macos_signing }}" --windows-signing "${{ needs.release-policy.outputs.windows_signing }}" --output src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json
- name: Create combined desktop update feed
shell: bash
run: |
mapfile -t mac_artifacts < <(find src-tauri/target/desktop-release-input/macos -name '*.app.tar.gz' -type f)
mapfile -t dmgs < <(find src-tauri/target/desktop-release-input/macos -name '*.dmg' -type f)
mapfile -t windows_artifacts < <(find src-tauri/target/desktop-release-input/windows -name '*.nsis.zip' -type f)
mapfile -t installers < <(find src-tauri/target/desktop-release-input/windows -name '*.exe' -type f)
if [[ ${#mac_artifacts[@]} -ne 1 || ${#dmgs[@]} -ne 1 || ${#windows_artifacts[@]} -ne 1 || ${#installers[@]} -ne 1 ]]; then
echo "Exactly one macOS updater/DMG and one Windows updater/installer are required."
exit 1
fi
include_args=(--include "${dmgs[0]}" --include "${installers[0]}" --include src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json)
notes_args=()
if [[ "${{ needs.release-policy.outputs.warning_required }}" == "true" ]]; then
mapfile -t warnings < <(find src-tauri/target/desktop-release-input/macos -name 'UNSIGNED-PLATFORM-RELEASE.txt' -type f)
if [[ ${#warnings[@]} -ne 1 ]]; then
echo "The unsigned-platform release warning is required."
exit 1
fi
include_args+=(--include "${warnings[0]}")
notes_args+=(--notes "UNSIGNED PLATFORM RELEASE: macOS is ad-hoc signed and not notarized; Windows is not Authenticode-signed. Gatekeeper and SmartScreen warnings are expected. Verify tag, commit, updater signatures, and SHA256SUMS.txt before installation.")
fi
npm run desktop:update-feed:create -- \
--artifact "${artifact}" \
--artifact "${mac_artifacts[0]}" \
--platform-artifact "windows-x86_64=${windows_artifacts[0]}" \
"${include_args[@]}" \
"${notes_args[@]}" \
--output-dir src-tauri/target/desktop-release-feed \
--base-url "${DESKTOP_UPDATE_BASE_URL}"
- name: Verify desktop update feed
run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}"
- name: Verify combined desktop update feed
run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}" --require-platform windows-x86_64 --require-provenance
- name: Upload verified desktop release directory
uses: actions/upload-artifact@v4
with:
name: ctms-desktop-release-${{ github.ref_name }}
name: ctms-desktop-release-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-feed/*
if-no-files-found: error
@@ -14,6 +14,8 @@ jobs:
windows-internal:
name: Windows NSIS internal validation
runs-on: windows-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults:
run:
working-directory: frontend
+5 -3
View File
@@ -2,7 +2,7 @@
桌面端项目计划书已完成并移除;当前桌面端执行约束以本文件为准。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知、自动更新和桌面端安全边界。历史设计只作追溯参考,见 `docs/desktop-phase-1-design.md``docs/desktop-phase-2-design.md``docs/audits/desktop-release-stabilization-checklist.md`
桌面端当前不是空白初始化项目。Tauri 基线、macOS 在线桌面壳和第二阶段原生能力主体已经形成;后续工作限于修复、稳定化、体验收口、发布准备、在线辅助缓存Windows 兼容验证。不得重新按第一阶段空白项目初始化 Tauri,不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。
桌面端当前不是空白初始化项目。Tauri 基线、macOS 在线桌面壳和第二阶段原生能力主体已经形成;后续工作限于修复、稳定化、体验收口、发布准备、在线辅助缓存Windows 兼容验证和已批准的 Windows 正式发布。不得重新按第一阶段空白项目初始化 Tauri,不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。
除非用户明确要求先调整本文件中的约束,否则不要实现离线功能、本地业务权威数据存储、内嵌后端服务、离线同步、本地优先工作流或新的阶段性桌面产品线。在线辅助本地缓存只允许作为已认证在线客户端的体验加速能力;处理桌面本地缓存、请求去重、条件请求、缓存诊断或缓存清理相关任务时,必须阅读并遵守 `docs/desktop-local-cache-plan.md`
@@ -14,7 +14,9 @@
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs``npm run runtime:check` 和桌面发布检查清单是否需要更新。
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
- Windows 仍只作为第二阶段兼容性验证目标;未获明确批准前不发布正式 Windows 安装包。Windows 内测构建只能使用 `.github/workflows/desktop-windows-internal.yml` 的手动验证入口,不得生成生产 `latest.json`、updater feed 或正式发布制品
- 正式桌面客户端的默认 CTMS 服务端入口必须由构建环境变量 `VITE_DESKTOP_SERVER_URL` 注入,不得在运行时代码中写死生产域名;用户仍可在桌面服务器设置中手动覆盖,手动值优先并沿用既有的切换服务器登出与缓存失效边界。该变量只表示业务服务端 origin,不得与 updater 制品前缀 `DESKTOP_UPDATE_BASE_URL` 混用
- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建。平台签名默认要求组织 Windows 代码签名证书、RFC 3161 时间戳和 Authenticode 校验;只有 `frontend/desktop-release-policy.json` 中按精确版本记录、经发布负责人批准的例外可跳过平台签名。无论是否采用平台签名例外,都必须使用 updater 私钥、校验 updater feed,并明确标记平台未签名风险。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。
- v0.1.0 另获准在 GitHub Actions 额度不可用时,从最终 `release` 上不可移动的 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS ad-hoc 制品;Windows 只能在额度恢复后从同一 tag/SHA 后补。macOS 先行阶段必须发布 updater `.sig`、checksum、provenance、`UNSIGNED-PLATFORM` 与 Windows pending 说明,但不得发布或替换生产 `latest.json`;只有 Windows `NotSigned` 制品和联合 feed 均验证通过后才能激活自动更新源。该本地/分阶段例外不适用于后续版本。
## 分支与发布治理
@@ -57,4 +59,4 @@ npm run desktop:build:app
本地缓存相关变更至少执行 `runtime:check``desktop:release:check``ui:contract``type-check``test:unit``build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
正式桌面发布构建必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述
正式桌面发布构建必须使用组织批准的 updater 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证、Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、发布说明、校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。该例外不自动适用于后续版本
+5 -5
View File
@@ -9,7 +9,7 @@
- 生产入口:`docker-compose.yaml`
- 初始化方式:`docker compose run --rm --build backend-init`
- 启动方式:`docker compose up -d --build`
- 运行拓扑:`nginx``backend``db`
- 运行拓扑:`nginx``backend``db``onlyoffice`
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
@@ -50,11 +50,11 @@
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `npm run version:check` 检查漂移。
- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`
## ONLYOFFICE 开发预览
## ONLYOFFICE 标准组件
- 普通开发环境仍不强制启动 Document Server
- 需要 Office 只读预览时执行 `bash scripts/onlyoffice-dev-up.sh`
- 脚本会自动生成并持久化本机开发密钥,不需要手工填写;详细说明`docs/guides/onlyoffice-preview.md`
- `./install.sh``bash scripts/install.sh dev|main|release` 默认生成独立 JWT、持久化实例标识并启动 Document Server,不再需要额外启用 Compose profile
- `bash scripts/onlyoffice-dev-up.sh` 仅保留为开发环境重建、健康验证和密钥轮换入口;普通安装无需再执行
- 默认镜像、字体、安全配置和生产许可要求`docs/guides/onlyoffice-preview.md`
## 仓库治理文档
- 分支治理规范:`docs/branch-governance.md`
@@ -0,0 +1,35 @@
"""Remove shared-library and passive Office preview audit noise.
Revision ID: 20260716_04
Revises: 20260716_03
"""
from alembic import op
revision = "20260716_04"
down_revision = "20260716_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
DELETE FROM audit_logs
WHERE action = 'OFFICE_PREVIEW_OPEN'
OR lower(entity_type) IN (
'faq_category',
'faq_item',
'faq_reply',
'faq_replies',
'precaution',
'knowledge_note',
'knowledge_notes',
'collaboration_file'
)
""")
def downgrade() -> None:
# 被清理的是明确排除出审计范围的共享库和被动预览记录,无法也不应伪造恢复。
pass
@@ -0,0 +1,63 @@
"""Expand generic notifications and route desktop delivery through them.
Revision ID: 20260716_05
Revises: 20260716_04
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260716_05"
down_revision = "20260716_04"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"notifications",
sa.Column("requires_action", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"notifications",
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
)
op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=True)
op.add_column(
"desktop_notification_deliveries",
sa.Column("notification_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_desktop_notification_deliveries_notification",
"desktop_notification_deliveries",
"notifications",
["notification_id"],
["id"],
ondelete="CASCADE",
)
op.create_unique_constraint(
"uq_desktop_notification_user_notification",
"desktop_notification_deliveries",
["user_id", "notification_id"],
)
def downgrade() -> None:
op.drop_constraint(
"uq_desktop_notification_user_notification",
"desktop_notification_deliveries",
type_="unique",
)
op.drop_constraint(
"fk_desktop_notification_deliveries_notification",
"desktop_notification_deliveries",
type_="foreignkey",
)
op.drop_column("desktop_notification_deliveries", "notification_id")
# Legacy rows always have a distribution id; rows created by the generic delivery path do not.
op.execute("DELETE FROM desktop_notification_deliveries WHERE distribution_id IS NULL")
op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=False)
op.drop_column("notifications", "due_at")
op.drop_column("notifications", "requires_action")
+34 -30
View File
@@ -77,6 +77,7 @@ FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
"read": "faq:read",
"delete": "faq_attachments:delete",
}
SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
"startup_kickoff",
"startup_kickoff_minutes",
@@ -239,16 +240,17 @@ async def upload_attachment(
content_type=file.content_type,
uploaded_by=current_user.id,
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FILE",
detail=f"文件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FILE",
detail=f"文件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return AttachmentRead(
id=attachment.id,
filename=attachment.filename,
@@ -442,16 +444,17 @@ async def global_delete_attachment(
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action(
db,
study_id=attachment.study_id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=user.id,
operator_role=await get_operator_role_label(db, attachment.study_id, user),
)
if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
await audit_crud.log_action(
db,
study_id=attachment.study_id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=user.id,
operator_role=await get_operator_role_label(db, attachment.study_id, user),
)
@router.delete(
@@ -494,13 +497,14 @@ async def delete_attachment(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+3 -3
View File
@@ -75,12 +75,12 @@ async def acknowledge_notifications(
)
@router.post("/{distribution_id}/read", status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{notification_id}/read", status_code=status.HTTP_204_NO_CONTENT)
async def mark_notification_read(
distribution_id: uuid.UUID,
notification_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await desktop_notification_service.mark_notification_read(
db, current_user.id, distribution_id
db, current_user.id, notification_id
)
+1 -36
View File
@@ -1,15 +1,11 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
from app.crud import faq_category as category_crud
from app.crud import faq_item as item_crud
from app.crud import member as member_crud
from app.schemas.common import PaginatedResponse
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
from app.utils.pagination import paginate
@@ -47,16 +43,6 @@ async def create_category(
if dup:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
category = await category_crud.create_category(db, payload)
await audit_crud.log_action(
db,
study_id=payload.study_id,
entity_type="faq_category",
entity_id=category.id,
action="CREATE_FAQ_CATEGORY",
detail=json.dumps({"targetName": category.name, "description": f"创建“{category.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
)
return CategoryRead.model_validate(category)
@@ -112,16 +98,6 @@ async def update_category(
if not target_study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
updated = await category_crud.update_category(db, category, payload)
await audit_crud.log_action(
db,
study_id=updated.study_id,
entity_type="faq_category",
entity_id=category_id,
action="UPDATE_FAQ_CATEGORY",
detail=json.dumps({"targetName": updated.name, "description": f"更新“{updated.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, updated.study_id, current_user),
)
return CategoryRead.model_validate(updated)
@@ -148,16 +124,5 @@ async def delete_category(
item_count = await item_crud.count_items_by_category(db, category_id)
if item_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
category_name = category.name
await db.delete(category)
await db.commit()
await audit_crud.log_action(
db,
study_id=category.study_id,
entity_type="faq_category",
entity_id=category_id,
action="DELETE_FAQ_CATEGORY",
detail=json.dumps({"targetName": category_name, "description": f"删除“{category_name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, category.study_id, current_user),
)
+1 -68
View File
@@ -1,12 +1,9 @@
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud
from app.crud import faq_reply as reply_crud
@@ -31,13 +28,6 @@ def _is_system_admin(current_user) -> bool:
return is_system_admin(current_user)
def _compact_text(value: str | None, max_length: int = 40) -> str:
text = " ".join(str(value or "").split())
if len(text) <= max_length:
return text
return f"{text[:max_length]}..."
@router.post(
"/",
response_model=FaqRead,
@@ -74,17 +64,6 @@ async def create_faq(
reply_in=FaqReplyCreate(content=payload.answer),
)
await faq_crud.set_status(db, item.id, "PROCESSING")
question_name = _compact_text(item.question)
await audit_crud.log_action(
db,
study_id=payload.study_id,
entity_type="faq_item",
entity_id=item.id,
action="CREATE_FAQ_ITEM",
detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
)
return FaqRead.model_validate(item)
@@ -178,19 +157,6 @@ async def update_faq(
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM"
question_name = _compact_text(updated.question)
detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}"}, ensure_ascii=False)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_item",
entity_id=item_id,
action=action,
detail=detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
return FaqRead.model_validate(updated)
@@ -337,17 +303,6 @@ async def create_reply(
if item.status != "RESOLVED":
await faq_crud.set_status(db, item.id, "PROCESSING")
await faq_crud.touch_item(db, item.id)
question_name = _compact_text(item.question)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_reply",
entity_id=reply.id,
action="CREATE_FAQ_REPLY",
detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
data = FaqReplyRead.model_validate(reply)
if quote:
if quote.is_deleted:
@@ -380,20 +335,9 @@ async def delete_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
question_name = _compact_text(item.question)
await reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item)
await db.commit()
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_item",
entity_id=item_id,
action="DELETE_FAQ_ITEM",
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
@router.delete(
@@ -415,7 +359,6 @@ async def delete_reply(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
question_name = _compact_text(item.question)
reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
@@ -438,13 +381,3 @@ async def delete_reply(
resolved_by_confirm=False,
)
await faq_crud.touch_item(db, item.id)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_reply",
entity_id=reply_id,
action="DELETE_FAQ_REPLY",
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
+10 -7
View File
@@ -1,4 +1,3 @@
import logging
import uuid
from fastapi import APIRouter, Depends, Response, status
@@ -9,7 +8,6 @@ from app.schemas.notification import GeneralNotificationFeed, GeneralNotificatio
from app.services import document_service, notification_service, project_reminder_service
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get(
@@ -40,20 +38,25 @@ async def list_notifications(
)
async def list_general_notifications(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 10,
category: str | None = None,
unread_only: bool = False,
requires_action: bool | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> GeneralNotificationFeed:
try:
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
except Exception:
await db.rollback()
logger.warning("Failed to synchronize legacy project reminders", exc_info=True)
# Fail closed: stale reminders may contain details for a permission that was just revoked.
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
return await notification_service.list_feed(
db,
study_id=study_id,
recipient_id=current_user.id,
skip=skip,
limit=limit,
category=category,
unread_only=unread_only,
requires_action=requires_action,
)
+1 -40
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import json
import os
import uuid
from pathlib import Path
@@ -11,9 +10,8 @@ from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists
from app.core.deps import get_current_user, get_db_session, get_operator_role_label
from app.core.deps import get_current_user, get_db_session
from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import document as document_crud
from app.crud import document_version as version_crud
from app.models.collaboration import CollaborationRevision
@@ -33,29 +31,6 @@ def _content_disposition(filename: str) -> str:
return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
async def _log_preview_open(
db: AsyncSession,
*,
study_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
current_user,
) -> None:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="ATTACHMENT" if resource_type == "attachment" else "DOCUMENT_VERSION",
entity_id=resource_id,
action="OFFICE_PREVIEW_OPEN",
detail=json.dumps(
{"resource_type": resource_type, "resource_id": str(resource_id), "result": "issued"},
ensure_ascii=True,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@router.get(
"/attachments/{attachment_id}/config",
response_model=OnlyOfficePreviewConfigRead,
@@ -98,13 +73,6 @@ async def get_attachment_preview_config(
user_id=current_user.id,
user_name=current_user.full_name,
)
await _log_preview_open(
db,
study_id=attachment.study_id,
resource_type="attachment",
resource_id=attachment.id,
current_user=current_user,
)
response.headers["Cache-Control"] = "no-store"
return result
@@ -151,13 +119,6 @@ async def get_version_preview_config(
user_id=current_user.id,
user_name=current_user.full_name,
)
await _log_preview_open(
db,
study_id=document.trial_id,
resource_type="version",
resource_id=version.id,
current_user=current_user,
)
response.headers["Cache-Control"] = "no-store"
return result
+1 -39
View File
@@ -1,11 +1,9 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import precaution as precaution_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
@@ -29,11 +27,6 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _precaution_audit_detail(action: str, precaution) -> str:
title = str(precaution.title or "").strip() or "注意事项"
return json.dumps({"targetName": title, "description": f"{action}注意事项“{title}"}, ensure_ascii=False)
@router.post(
"/precautions",
response_model=PrecautionRead,
@@ -49,16 +42,6 @@ async def create_precaution(
await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, precaution_in.site_name)
precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution.id,
action="CREATE_PRECAUTION",
detail=_precaution_audit_detail("创建", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return PrecautionRead.model_validate(precaution)
@@ -115,16 +98,6 @@ async def update_precaution(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution_id,
action="UPDATE_PRECAUTION",
detail=_precaution_audit_detail("更新", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return PrecautionRead.model_validate(precaution)
@@ -144,15 +117,4 @@ async def delete_precaution(
if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution_detail = _precaution_audit_detail("删除", precaution)
await precaution_crud.delete_precaution(db, precaution)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution_id,
action="DELETE_PRECAUTION",
detail=precaution_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+18
View File
@@ -12,6 +12,7 @@ from app.crud import study as study_crud
from app.crud import visit as visit_crud
from app.schemas.subject import SubjectUpdate
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate
from app.services import notification_service
router = APIRouter()
@@ -42,6 +43,17 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
async def _resolve_visit_reminders(db: AsyncSession, visit_ids: list[uuid.UUID]) -> None:
for visit_id in set(visit_ids):
await notification_service.resolve_source_notifications(
db,
source_type="SUBJECT_VISIT_WINDOW",
source_id=str(visit_id),
)
if visit_ids:
await db.commit()
@router.get(
"/",
response_model=list[VisitRead],
@@ -145,6 +157,8 @@ async def create_early_termination(
if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期")
existing_visit_ids = [visit.id for visit in await visit_crud.list_visits(db, subject_id)]
try:
visit = await visit_crud.create_early_termination_visit(
db,
@@ -164,6 +178,7 @@ async def create_early_termination(
drop_reason=reason,
),
)
await _resolve_visit_reminders(db, existing_visit_ids)
await audit_crud.log_action(
db,
study_id=study_id,
@@ -209,6 +224,8 @@ async def update_visit(
old_status = visit.status
updated = await visit_crud.update_visit(db, visit, visit_in)
await subject_crud.sync_subject_status(db, subject)
if updated.actual_date is not None or updated.status in {"DONE", "CANCELLED"}:
await _resolve_visit_reminders(db, [updated.id])
detail = None
if visit_in.status:
detail = json.dumps(
@@ -253,6 +270,7 @@ async def delete_visit(
visit_detail = _visit_audit_detail("删除", subject, visit)
await visit_crud.delete_visit(db, visit)
await subject_crud.sync_subject_status(db, subject)
await _resolve_visit_reminders(db, [visit_id])
await audit_crud.log_action(
db,
study_id=study_id,
+1
View File
@@ -51,6 +51,7 @@ class Settings(BaseSettings):
MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800)
USER_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650)
USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600)
NOTIFICATION_SYNC_INTERVAL_SECONDS: int = Field(default=300, ge=60, le=3600)
ONLYOFFICE_ENABLED: bool = False
ONLYOFFICE_JWT_SECRET: Optional[str] = None
ONLYOFFICE_INTERNAL_URL: str = "http://onlyoffice"
+3
View File
@@ -20,6 +20,7 @@ from app.crud.user import ensure_admin_exists
from app.db.base import Base
from app.db.session import SessionLocal, engine
from app.services.visit_scheduler import run_daily_lost_visit_job
from app.services.notification_scheduler import run_notification_sync_job
from app.services.permission_log_writer import start_log_writer, stop_log_writer
from app.services.permission_metric_aggregator import run_hourly_metric_aggregation
from app.services.source_location_aggregator import run_hourly_source_location_aggregation
@@ -67,6 +68,7 @@ async def lifespan(_: FastAPI):
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session:
await ensure_admin_exists(session)
notification_scheduler_task = asyncio.create_task(run_notification_sync_job(stop_event))
retention_task = asyncio.create_task(run_monitoring_retention(stop_event))
yield
stop_event.set()
@@ -75,6 +77,7 @@ async def lifespan(_: FastAPI):
await scheduler_task
await aggregator_task
await source_location_aggregator_task
await notification_scheduler_task
await retention_task
+6 -2
View File
@@ -28,6 +28,7 @@ class DesktopNotificationDelivery(Base):
__tablename__ = "desktop_notification_deliveries"
__table_args__ = (
UniqueConstraint("user_id", "distribution_id", name="uq_desktop_notification_user_distribution"),
UniqueConstraint("user_id", "notification_id", name="uq_desktop_notification_user_notification"),
Index("ix_desktop_notification_claim", "user_id", "delivered_at", "claimed_at"),
)
@@ -35,8 +36,11 @@ class DesktopNotificationDelivery(Base):
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
distribution_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=False
distribution_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=True
)
notification_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("notifications.id", ondelete="CASCADE"), nullable=True
)
claim_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+5 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -41,6 +41,10 @@ class Notification(Base):
source_id: Mapped[str] = mapped_column(String(100), nullable=False)
source_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
dedupe_key: Mapped[str] = mapped_column(String(255), nullable=False)
requires_action: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+15 -11
View File
@@ -37,17 +37,6 @@ class DesktopNotificationClaimRequest(BaseModel):
limit: int = 20
class DesktopNotificationClaimResponse(BaseModel):
claim_token: uuid.UUID | None = None
lease_expires_at: datetime | None = None
items: list[NotificationItem]
class DesktopNotificationAckRequest(BaseModel):
claim_token: uuid.UUID
delivered_ids: list[uuid.UUID]
class GeneralNotificationRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
@@ -59,12 +48,27 @@ class GeneralNotificationRead(BaseModel):
action_path: str | None = None
source_type: str
source_id: str
requires_action: bool = True
due_at: datetime | None = None
read_at: datetime | None = None
resolved_at: datetime | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class DesktopNotificationClaimResponse(BaseModel):
claim_token: uuid.UUID | None = None
lease_expires_at: datetime | None = None
items: list[GeneralNotificationRead]
class DesktopNotificationAckRequest(BaseModel):
claim_token: uuid.UUID
delivered_ids: list[uuid.UUID]
class GeneralNotificationFeed(BaseModel):
unread_count: int
total_count: int
items: list[GeneralNotificationRead]
+21 -96
View File
@@ -16,9 +16,8 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.deps import get_operator_role_label, is_system_admin
from app.core.deps import is_system_admin
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.crud import member as member_crud
from app.models.collaboration import (
CollaborationEditRequest,
@@ -427,23 +426,6 @@ async def require_ownership_transfer(db: AsyncSession, item: CollaborationFile,
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您没有转让此协作文件所有权的权限")
async def _audit(db: AsyncSession, item: CollaborationFile, action: str, user, extra: dict | None = None) -> None:
detail = {"file_id": str(item.id), "title": item.title}
if extra:
detail.update(extra)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="COLLABORATION_FILE",
entity_id=item.id,
action=action,
detail=json.dumps(detail, ensure_ascii=False),
operator_id=user.id,
operator_role=await get_operator_role_label(db, item.study_id, user),
auto_commit=False,
)
async def create_folder(db: AsyncSession, study_id: uuid.UUID, payload: CollaborationFolderCreate, user) -> CollaborationFolder:
if payload.parent_id:
await _folder_or_404(db, study_id, payload.parent_id)
@@ -618,7 +600,6 @@ async def _create_file_from_bytes(
content: bytes,
source: str,
user,
source_file_id: uuid.UUID | None = None,
) -> CollaborationFile:
membership = await member_crud.get_member(db, study_id, user.id)
await _require_role_assignable(db, study_id, user, membership, "MANAGER")
@@ -637,10 +618,6 @@ async def _create_file_from_bytes(
await db.flush()
db.add(CollaborationMember(file_id=item.id, user_id=user.id, role="MANAGER", invited_by=user.id))
await _persist_revision_bytes(db, item, content, source=source, created_by=user.id, original_filename=item.title)
audit_detail = {"source": source}
if source_file_id:
audit_detail["source_file_id"] = str(source_file_id)
await _audit(db, item, "COLLABORATION_FILE_CREATED", user, audit_detail)
await db.commit()
await db.refresh(item)
return item
@@ -775,11 +752,6 @@ async def update_file(
db: AsyncSession, item: CollaborationFile, payload: CollaborationFileUpdate, user
) -> CollaborationFile:
await require_file_manager(db, item, user)
previous_title = item.title
previous_folder_id = item.folder_id
previous_allow_export = item.allow_export
previous_allow_edit_request = item.allow_edit_request
previous_allow_sheet_structure_edit = item.allow_sheet_structure_edit
values = payload.model_dump(exclude_unset=True)
if "folder_id" in values and values["folder_id"]:
await _folder_or_404(db, item.study_id, values["folder_id"])
@@ -810,25 +782,6 @@ async def update_file(
item.generation += 1
for key, value in values.items():
setattr(item, key, value)
title_changed = item.title != previous_title
folder_changed = item.folder_id != previous_folder_id
if title_changed and not folder_changed:
action = "COLLABORATION_FILE_RENAMED"
elif folder_changed and not title_changed:
action = "COLLABORATION_FILE_MOVED"
else:
action = "COLLABORATION_FILE_UPDATED"
await _audit(db, item, action, user, {
"previous_title": previous_title,
"previous_folder_id": str(previous_folder_id) if previous_folder_id else None,
"folder_id": str(item.folder_id) if item.folder_id else None,
"previous_allow_export": previous_allow_export,
"allow_export": item.allow_export,
"previous_allow_edit_request": previous_allow_edit_request,
"allow_edit_request": item.allow_edit_request,
"previous_allow_sheet_structure_edit": previous_allow_sheet_structure_edit,
"allow_sheet_structure_edit": item.allow_sheet_structure_edit,
})
await db.commit()
await db.refresh(item)
return item
@@ -874,7 +827,6 @@ async def copy_file(db: AsyncSession, item: CollaborationFile, user) -> Collabor
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@@ -885,14 +837,6 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可下载版本")
if not Path(revision.file_uri).is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件当前版本不存在")
await _audit(
db,
item,
"COLLABORATION_FILE_DOWNLOADED",
user,
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
)
await db.commit()
return revision
@@ -900,7 +844,6 @@ async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None
await require_file_manager(db, item, user)
item.status = "DELETED"
item.deleted_at = datetime.now(timezone.utc)
await _audit(db, item, "COLLABORATION_FILE_TRASHED", user)
await db.commit()
@@ -908,7 +851,6 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
await require_file_manager(db, item, user)
item.status = "ACTIVE"
item.deleted_at = None
await _audit(db, item, "COLLABORATION_FILE_RESTORED", user)
await db.commit()
await db.refresh(item)
return item
@@ -916,14 +858,10 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
async def record_export(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
await require_file_exporter(db, item, user)
await _audit(db, item, "COLLABORATION_FILE_EXPORTED", user, {"file_type": file_type})
await db.commit()
async def record_download(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
await require_file_exporter(db, item, user)
await _audit(db, item, "COLLABORATION_FILE_DOWNLOADED", user, {"file_type": file_type})
await db.commit()
async def list_revisions(db: AsyncSession, item: CollaborationFile) -> list[CollaborationRevisionRead]:
@@ -967,10 +905,6 @@ async def delete_revision(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前版本不能删除")
revision.deleted_at = datetime.now(timezone.utc)
revision.deleted_by = user.id
await _audit(db, item, "COLLABORATION_REVISION_DELETED", user, {
"revision_id": str(revision.id),
"revision_no": revision.revision_no,
})
await db.commit()
@@ -984,13 +918,6 @@ async def update_revision(
await require_file_editor(db, item, user)
revision = await get_revision_or_404(db, item, revision_id)
revision.change_summary = payload.change_summary
await _audit(
db,
item,
"COLLABORATION_REVISION_NAMED",
user,
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
)
await db.commit()
await db.refresh(revision)
return revision
@@ -1002,14 +929,6 @@ async def prepare_revision_preview(
revision = await get_revision_or_404(db, item, revision_id)
if not Path(revision.file_uri).is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作修订内容不存在")
await _audit(
db,
item,
"COLLABORATION_REVISION_PREVIEWED",
user,
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
)
await db.commit()
return revision
@@ -1040,7 +959,6 @@ async def copy_revision(
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@@ -1063,7 +981,6 @@ async def restore_revision(
locked = await db.get(CollaborationFile, item.id)
if locked:
locked.generation += 1
await _audit(db, item, "COLLABORATION_REVISION_RESTORED", user, {"revision_no": revision.revision_no})
await db.commit()
await db.refresh(restored)
return restored
@@ -1111,7 +1028,6 @@ async def upsert_member(
else:
member = CollaborationMember(file_id=item.id, user_id=payload.user_id, role=payload.role, invited_by=user.id)
db.add(member)
await _audit(db, item, "COLLABORATION_MEMBER_UPSERTED", user, {"member_id": str(payload.user_id), "role": payload.role})
await db.commit()
await db.refresh(member)
return CollaborationMemberRead(
@@ -1132,7 +1048,6 @@ async def remove_member(db: AsyncSession, item: CollaborationFile, user_id: uuid
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作成员不存在")
await db.delete(member)
await _audit(db, item, "COLLABORATION_MEMBER_REMOVED", user, {"member_id": str(user_id)})
await db.commit()
@@ -1192,7 +1107,6 @@ async def create_edit_request(
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
db.add(request)
await db.flush()
await _audit(db, item, "COLLABORATION_EDIT_REQUESTED", user, {"request_id": str(request.id)})
manager_ids = set((await db.scalars(
select(StudyMember.user_id)
.outerjoin(
@@ -1225,6 +1139,7 @@ async def create_edit_request(
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request:{request.id}",
source_version="PENDING",
)
await db.commit()
await db.refresh(request)
@@ -1286,16 +1201,30 @@ async def resolve_edit_request(
request.status = payload.status
request.resolved_by = user.id
request.resolved_at = datetime.now(timezone.utc)
approved = payload.status == "APPROVED"
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=[request.requester_id],
category="COLLABORATION_EDIT_REQUEST_RESULT",
priority="NORMAL",
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
action_path=(
f"/knowledge/collaboration/{item.id}"
if approved
else "/knowledge/collaboration"
),
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request-result:{request.id}",
requires_action=False,
)
await notification_service.resolve_source_notifications(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
await _audit(db, item, "COLLABORATION_EDIT_REQUEST_RESOLVED", user, {
"request_id": str(request.id),
"requester_id": str(request.requester_id),
"status": payload.status,
})
await db.commit()
await db.refresh(request)
return _edit_request_read(request, requester)
@@ -1341,10 +1270,6 @@ async def transfer_ownership(
previous_owner_member.role = "MANAGER"
locked.owner_id = payload.new_owner_id
locked.updated_at = datetime.now(timezone.utc)
await _audit(db, locked, "COLLABORATION_OWNERSHIP_TRANSFERRED", user, {
"previous_owner_id": str(previous_owner_id),
"new_owner_id": str(payload.new_owner_id),
})
await db.commit()
await db.refresh(locked)
return locked
@@ -177,19 +177,6 @@ async def update_share_link(
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
await collaboration_service._audit(
db,
item,
"COLLABORATION_SHARE_LINK_UPDATED",
user,
{
"enabled": link.enabled,
"access_mode": link.access_mode,
"file_allow_export": item.allow_export,
"expiry_policy": link.expiry_policy,
"has_password": bool(link.password_hash),
},
)
await db.commit()
await db.refresh(link)
return share_link_read(link)
@@ -10,12 +10,9 @@ from app.models.desktop_notification import (
DesktopNotificationDelivery,
DesktopNotificationSubscription,
)
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
from app.models.document import Document
from app.models.document_version import DocumentVersion
from app.models.study import Study
from app.models.notification import Notification
from app.models.study_member import StudyMember
from app.schemas.notification import NotificationItem
from app.models.user import User
CLAIM_LEASE = timedelta(minutes=5)
@@ -47,46 +44,28 @@ async def set_subscription(
def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime):
role_target_exists = exists(
active_membership = exists(
select(StudyMember.id).where(
StudyMember.study_id == Document.trial_id,
StudyMember.study_id == Notification.study_id,
StudyMember.user_id == user_id,
StudyMember.is_active.is_(True),
StudyMember.role_in_study == Distribution.target_id,
)
)
target_matches = or_(
and_(
Distribution.target_type == DistributionTargetType.USER,
Distribution.target_id == str(user_id),
),
and_(
Distribution.target_type == DistributionTargetType.ROLE,
role_target_exists,
),
)
return (
select(
Distribution,
Document,
DocumentVersion,
Study,
DesktopNotificationDelivery,
)
.join(Document, Distribution.document_id == Document.id)
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
.join(Study, Document.trial_id == Study.id)
select(Notification, DesktopNotificationDelivery)
.outerjoin(
DesktopNotificationDelivery,
and_(
DesktopNotificationDelivery.distribution_id == Distribution.id,
DesktopNotificationDelivery.notification_id == Notification.id,
DesktopNotificationDelivery.user_id == user_id,
),
)
.where(
Distribution.status == DistributionStatus.ACTIVE,
Distribution.created_at >= enabled_at,
target_matches,
Notification.recipient_id == user_id,
Notification.created_at >= enabled_at,
Notification.resolved_at.is_(None),
Notification.read_at.is_(None),
active_membership,
or_(
DesktopNotificationDelivery.id.is_(None),
and_(
@@ -98,8 +77,8 @@ def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: date
),
),
)
.order_by(Distribution.created_at.asc())
.with_for_update(of=Distribution, skip_locked=True)
.order_by(Notification.created_at.asc())
.with_for_update(of=Notification, skip_locked=True)
)
@@ -107,45 +86,46 @@ async def claim_notifications(
db: AsyncSession,
user_id: uuid.UUID,
limit: int,
) -> tuple[uuid.UUID | None, datetime | None, list[NotificationItem]]:
) -> tuple[uuid.UUID | None, datetime | None, list[Notification]]:
subscription = await get_subscription(db, user_id)
if not subscription or not subscription.enabled or not subscription.enabled_at:
return None, None, []
# Reconcile every active project before selecting desktop deliveries so permission
# changes fail closed and time-based reminders do not depend on opening the web Feed.
user = await db.get(User, user_id)
if user is None or not user.is_active:
return None, None, []
study_ids = (await db.scalars(
select(StudyMember.study_id).where(
StudyMember.user_id == user_id,
StudyMember.is_active.is_(True),
)
)).all()
from app.services.project_reminder_service import sync_project_reminders
for study_id in set(study_ids):
await sync_project_reminders(db, study_id, user)
now = datetime.now(timezone.utc)
token = uuid.uuid4()
rows = (
await db.execute(
_eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE).limit(max(1, min(limit, 50)))
_eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE)
.limit(max(1, min(limit, 50)))
)
).all()
items: list[NotificationItem] = []
for distribution, document, version, study, delivery in rows:
items: list[Notification] = []
for notification, delivery in rows:
if delivery is None:
delivery = DesktopNotificationDelivery(
user_id=user_id,
distribution_id=distribution.id,
notification_id=notification.id,
)
db.add(delivery)
delivery.claim_token = token
delivery.claimed_at = now
items.append(
NotificationItem(
id=distribution.id,
document_id=document.id,
version_id=version.id,
document_title=document.title,
document_no=document.doc_no,
version_no=version.version_no,
change_summary=version.change_summary,
effective_at=version.effective_at,
created_at=distribution.created_at,
study_id=study.id,
study_name=study.name,
delivered_at=delivery.delivered_at,
read_at=delivery.read_at,
)
)
items.append(notification)
await db.commit()
if not items:
return None, None, []
@@ -164,7 +144,7 @@ async def acknowledge_notifications(
.where(
DesktopNotificationDelivery.user_id == user_id,
DesktopNotificationDelivery.claim_token == claim_token,
DesktopNotificationDelivery.distribution_id.in_(delivered_ids),
DesktopNotificationDelivery.notification_id.in_(delivered_ids),
)
.values(delivered_at=datetime.now(timezone.utc))
)
@@ -174,21 +154,17 @@ async def acknowledge_notifications(
async def mark_notification_read(
db: AsyncSession,
user_id: uuid.UUID,
distribution_id: uuid.UUID,
notification_id: uuid.UUID,
) -> None:
delivery = (
await db.execute(
select(DesktopNotificationDelivery).where(
DesktopNotificationDelivery.user_id == user_id,
DesktopNotificationDelivery.distribution_id == distribution_id,
)
)
).scalar_one_or_none()
if delivery is None:
delivery = DesktopNotificationDelivery(
user_id=user_id,
distribution_id=distribution_id,
)
db.add(delivery)
delivery.read_at = datetime.now(timezone.utc)
item = await db.scalar(select(Notification).where(
Notification.id == notification_id,
Notification.recipient_id == user_id,
Notification.resolved_at.is_(None),
))
if item is None:
return
now = datetime.now(timezone.utc)
item.read_at = item.read_at or now
if not item.requires_action:
item.resolved_at = item.resolved_at or now
await db.commit()
+22 -2
View File
@@ -11,7 +11,7 @@ from urllib.parse import quote
import aiofiles
from fastapi import HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update
from sqlalchemy import String, and_, cast, delete as sa_delete, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope
@@ -32,6 +32,7 @@ from app.models.distribution import Distribution, DistributionStatus, Distributi
from app.models.document import Document, DocumentScopeType, DocumentStatus
from app.models.document_version import DocumentVersion, DocumentVersionStatus
from app.models.desktop_notification import DesktopNotificationDelivery
from app.models.notification import Notification
from app.models.study import Study
from app.schemas.acknowledgement import AcknowledgementCreate
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
@@ -39,6 +40,7 @@ from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
from app.schemas.notification import NotificationItem
from app.schemas.user import UserDisplay
from app.services import notification_service
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents"
@@ -688,6 +690,10 @@ async def create_acknowledgement(
if distribution.target_type == DistributionTargetType.ROLE:
if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
if distribution.target_type == DistributionTargetType.SITE and not is_system_admin(current_user):
site_ids = await site_crud.list_ids_by_contact_user(db, doc.trial_id, current_user.id)
if distribution.target_id not in {str(site_id) for site_id in site_ids}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发中心范围内")
if payload.ack_type != AcknowledgementType.RECEIVED:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执")
@@ -722,6 +728,12 @@ async def create_acknowledgement(
operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
)
)
await notification_service.resolve_source_notifications(
db,
source_type="DOCUMENT_DISTRIBUTION",
source_id=str(distribution.id),
recipient_id=current_user.id,
)
await db.commit()
await db.refresh(ack)
return ack
@@ -812,10 +824,18 @@ async def list_distribution_notifications(
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
.join(Document, Distribution.document_id == Document.id)
.join(Study, Document.trial_id == Study.id)
.outerjoin(
Notification,
and_(
Notification.source_type == "DOCUMENT_DISTRIBUTION",
Notification.source_id == cast(Distribution.id, String),
Notification.recipient_id == current_user.id,
),
)
.outerjoin(
DesktopNotificationDelivery,
and_(
DesktopNotificationDelivery.distribution_id == Distribution.id,
DesktopNotificationDelivery.notification_id == Notification.id,
DesktopNotificationDelivery.user_id == current_user.id,
),
)
@@ -0,0 +1,78 @@
"""Server-side materialization for time and state driven business reminders."""
from __future__ import annotations
import asyncio
import logging
from sqlalchemy import select, text
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.study_member import StudyMember
from app.models.user import User, UserStatus
from app.services.project_reminder_service import sync_project_reminders
logger = logging.getLogger("ctms.notifications")
NOTIFICATION_SYNC_LOCK_KEY = 0x43544D53 # "CTMS"
async def sync_all_project_reminders_once() -> int:
"""Synchronize every active project member once, with a cross-worker advisory lock."""
async with SessionLocal() as lock_session:
acquired = bool(await lock_session.scalar(
text("SELECT pg_try_advisory_lock(:lock_key)"),
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
))
if not acquired:
return 0
try:
memberships = (await lock_session.execute(
select(StudyMember.study_id, StudyMember.user_id)
.join(User, User.id == StudyMember.user_id)
.where(
StudyMember.is_active.is_(True),
User.status == UserStatus.ACTIVE,
)
)).all()
await lock_session.commit()
synced = 0
for study_id, user_id in memberships:
async with SessionLocal() as session:
try:
user = await session.get(User, user_id)
if user is None or not user.is_active:
continue
await sync_project_reminders(session, study_id, user)
synced += 1
except Exception:
await session.rollback()
logger.exception(
"Failed to synchronize reminders for study=%s user=%s",
study_id,
user_id,
)
return synced
finally:
await lock_session.execute(
text("SELECT pg_advisory_unlock(:lock_key)"),
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
)
async def run_notification_sync_job(stop_event: asyncio.Event) -> None:
logger.info("Notification synchronization task started")
while not stop_event.is_set():
try:
synced = await sync_all_project_reminders_once()
logger.debug("Synchronized reminders for %s project memberships", synced)
except Exception:
logger.exception("Notification synchronization task failed")
try:
await asyncio.wait_for(
stop_event.wait(),
timeout=settings.NOTIFICATION_SYNC_INTERVAL_SECONDS,
)
except asyncio.TimeoutError:
continue
logger.info("Notification synchronization task stopped")
+188 -8
View File
@@ -8,10 +8,24 @@ from fastapi import HTTPException, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.desktop_notification import DesktopNotificationDelivery
from app.models.notification import Notification
from app.schemas.notification import GeneralNotificationFeed
async def _requeue_desktop_delivery(db: AsyncSession, notification_id: uuid.UUID) -> None:
"""Allow an escalated/reopened reminder to be delivered again by the desktop channel."""
await db.execute(
update(DesktopNotificationDelivery)
.where(DesktopNotificationDelivery.notification_id == notification_id)
.values(
claim_token=None,
claimed_at=None,
delivered_at=None,
)
)
async def create_recipient_notifications(
db: AsyncSession,
*,
@@ -25,6 +39,9 @@ async def create_recipient_notifications(
source_type: str,
source_id: str,
dedupe_key: str,
source_version: str | None = None,
requires_action: bool = True,
due_at: datetime | None = None,
) -> None:
recipients = set(recipient_ids)
if not recipients:
@@ -46,10 +63,78 @@ async def create_recipient_notifications(
action_path=action_path,
source_type=source_type,
source_id=source_id,
source_version=source_version,
dedupe_key=dedupe_key,
requires_action=requires_action,
due_at=due_at,
))
async def sync_state_notification(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
category: str,
priority: str,
title: str,
message: str,
action_path: str,
source_type: str,
source_id: str,
source_version: str,
active: bool,
due_at: datetime | None = None,
requires_action: bool = True,
dedupe_key: str | None = None,
) -> None:
"""Synchronize one actionable business state into a recipient reminder."""
dedupe_key = dedupe_key or f"state:{source_type}:{source_id}"
item = await db.scalar(select(Notification).where(
Notification.recipient_id == recipient_id,
Notification.dedupe_key == dedupe_key,
))
now = datetime.now(timezone.utc)
if not active:
if item and item.resolved_at is None:
item.resolved_at = now
item.read_at = item.read_at or now
return
if item is None:
db.add(Notification(
study_id=study_id,
recipient_id=recipient_id,
category=category,
priority=priority,
title=title,
message=message,
action_path=action_path,
source_type=source_type,
source_id=source_id,
source_version=source_version,
dedupe_key=dedupe_key,
requires_action=requires_action,
due_at=due_at,
))
return
should_notify_again = item.resolved_at is not None or item.source_version != source_version
item.study_id = study_id
item.category = category
item.priority = priority
item.title = title
item.message = message
item.action_path = action_path
item.source_version = source_version
item.requires_action = requires_action
item.due_at = due_at
if should_notify_again:
item.read_at = None
item.created_at = now
await _requeue_desktop_delivery(db, item.id)
item.resolved_at = None
async def sync_aggregate_notification(
db: AsyncSession,
*,
@@ -63,6 +148,7 @@ async def sync_aggregate_notification(
source_type: str,
source_id: str,
count: int,
due_at: datetime | None = None,
) -> None:
dedupe_key = f"aggregate:{source_type}:{source_id}"
item = await db.scalar(select(Notification).where(
@@ -89,32 +175,83 @@ async def sync_aggregate_notification(
source_id=source_id,
source_version=version,
dedupe_key=dedupe_key,
requires_action=True,
due_at=due_at,
))
return
previous_count = int(item.source_version or 0)
item.category = category
item.priority = priority
item.title = title
item.message = message
item.action_path = action_path
item.source_version = version
item.due_at = due_at
if item.resolved_at is not None or count > previous_count:
item.read_at = None
item.created_at = now
await _requeue_desktop_delivery(db, item.id)
item.resolved_at = None
async def resolve_missing_recipient_sources(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
source_type: str,
active_source_ids: set[str],
) -> None:
filters = [
Notification.study_id == study_id,
Notification.recipient_id == recipient_id,
Notification.source_type == source_type,
Notification.resolved_at.is_(None),
]
if active_source_ids:
filters.append(Notification.source_id.not_in(active_source_ids))
now = datetime.now(timezone.utc)
await db.execute(
update(Notification)
.where(*filters)
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
)
async def resolve_source_notifications(
db: AsyncSession,
*,
source_type: str,
source_id: str,
recipient_id: uuid.UUID | None = None,
) -> None:
filters = [
Notification.source_type == source_type,
Notification.source_id == source_id,
Notification.resolved_at.is_(None),
]
if recipient_id is not None:
filters.append(Notification.recipient_id == recipient_id)
now = datetime.now(timezone.utc)
await db.execute(
update(Notification)
.where(*filters)
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
)
async def resolve_recipient_study_notifications(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
) -> None:
now = datetime.now(timezone.utc)
await db.execute(
update(Notification)
.where(
Notification.source_type == source_type,
Notification.source_id == source_id,
Notification.study_id == study_id,
Notification.recipient_id == recipient_id,
Notification.resolved_at.is_(None),
)
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
@@ -126,7 +263,11 @@ async def list_feed(
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
skip: int = 0,
limit: int = 10,
category: str | None = None,
unread_only: bool = False,
requires_action: bool | None = None,
) -> GeneralNotificationFeed:
active_filter = (
Notification.study_id == study_id,
@@ -136,13 +277,36 @@ async def list_feed(
unread_count = int(await db.scalar(
select(func.count(Notification.id)).where(*active_filter, Notification.read_at.is_(None))
) or 0)
filters = [
Notification.study_id == study_id,
Notification.recipient_id == recipient_id,
Notification.resolved_at.is_(None),
]
if category:
filters.append(Notification.category == category)
if unread_only:
filters.append(Notification.read_at.is_(None))
if requires_action is not None:
filters.append(Notification.requires_action.is_(requires_action))
total_count = int(await db.scalar(
select(func.count(Notification.id)).where(*filters)
) or 0)
items = (await db.scalars(
select(Notification)
.where(*active_filter)
.order_by(Notification.read_at.is_not(None), Notification.created_at.desc())
.limit(max(1, min(limit, 50)))
.where(*filters)
.order_by(
Notification.read_at.is_not(None),
Notification.created_at.desc(),
)
.offset(max(0, skip))
.limit(max(1, min(limit, 100)))
)).all()
return GeneralNotificationFeed(unread_count=unread_count, items=list(items))
return GeneralNotificationFeed(
unread_count=unread_count,
total_count=total_count,
items=list(items),
)
async def mark_read(
@@ -161,7 +325,10 @@ async def mark_read(
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
if item.read_at is None:
item.read_at = datetime.now(timezone.utc)
now = datetime.now(timezone.utc)
item.read_at = now
if not item.requires_action:
item.resolved_at = now
await db.commit()
await db.refresh(item)
return item
@@ -173,6 +340,7 @@ async def mark_all_read(
study_id: uuid.UUID,
recipient_id: uuid.UUID,
) -> None:
now = datetime.now(timezone.utc)
await db.execute(
update(Notification)
.where(
@@ -180,7 +348,19 @@ async def mark_all_read(
Notification.recipient_id == recipient_id,
Notification.resolved_at.is_(None),
Notification.read_at.is_(None),
Notification.requires_action.is_(False),
)
.values(read_at=datetime.now(timezone.utc))
.values(read_at=now, resolved_at=now)
)
await db.execute(
update(Notification)
.where(
Notification.study_id == study_id,
Notification.recipient_id == recipient_id,
Notification.resolved_at.is_(None),
Notification.read_at.is_(None),
Notification.requires_action.is_(True),
)
.values(read_at=now)
)
await db.commit()
@@ -396,11 +396,6 @@ async def process_callback(
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document
# Server 缓存重建时仍从最近一次持久化内容恢复。
session.base_revision_id = revision.id
if created and actor:
await collaboration_service._audit(
db, item, "COLLABORATION_REVISION_SAVED", actor,
{"revision_no": revision.revision_no, "source": source},
)
elif payload.status == 4:
session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc)
+490 -21
View File
@@ -1,61 +1,152 @@
from __future__ import annotations
import uuid
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, is_system_admin
from app.core.project_permissions import role_has_api_permission
from app.crud import ae as ae_crud
from app.crud import member as member_crud
from app.crud import monitoring_visit_issue as monitoring_issue_crud
from app.crud import site as site_crud
from app.models.acknowledgement import Acknowledgement, AcknowledgementType
from app.models.ae import AdverseEvent
from app.models.collaboration import CollaborationEditRequest, CollaborationFile, CollaborationMember
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
from app.models.document import Document, DocumentStatus
from app.models.document_version import DocumentVersion
from app.models.milestone import Milestone
from app.models.monitoring_visit_issue import MonitoringVisitIssue
from app.models.site import Site
from app.models.subject import Subject
from app.models.user import User
from app.models.visit import Visit
from app.services import notification_service
BUSINESS_TIMEZONE = ZoneInfo("Asia/Shanghai")
RISK_DUE_SOON_DAYS = 3
MILESTONE_DUE_SOON_DAYS = 7
VISIT_WINDOW_DUE_SOON_DAYS = 3
async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) -> None:
membership = await member_crud.get_member(db, study_id, user.id)
if not is_system_admin(user) and (not membership or not membership.is_active):
return
role = membership.role_in_study if membership else ""
def _date_due_at(value: date | None) -> datetime | None:
if value is None:
return None
return datetime.combine(value, time.max, tzinfo=BUSINESS_TIMEZONE).astimezone(timezone.utc)
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
async def _count_and_earliest(db: AsyncSession, model, *filters):
return (await db.execute(
select(func.count(model.id), func.min(model.report_due_date if model is AdverseEvent else model.due_at))
.where(*filters)
)).one()
async def _sync_risk_reminders(
db: AsyncSession,
*,
study_id: uuid.UUID,
user,
role: str,
current_time: datetime,
) -> None:
can_read_aes = is_system_admin(user) or await role_has_api_permission(
db, study_id, role, "subject_aes:read"
)
can_read_monitoring = is_system_admin(user) or await role_has_api_permission(
db, study_id, role, "monitoring_issues:read"
)
cra_scope = await get_cra_site_scope(db, study_id, user)
site_ids = cra_scope[0] if cra_scope else None
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
due_soon_date = today + timedelta(days=RISK_DUE_SOON_DAYS)
ae_scope = [AdverseEvent.study_id == study_id, AdverseEvent.status != "CLOSED"]
if site_ids is not None:
ae_scope.append(AdverseEvent.site_id.in_(site_ids))
overdue_aes = 0
overdue_ae_due = None
due_soon_aes = 0
due_soon_ae_due = None
if can_read_aes:
cra_scope = await get_cra_site_scope(db, study_id, user)
items = await ae_crud.list_ae(
overdue_aes, overdue_ae_due = await _count_and_earliest(
db,
study_id,
overdue=True,
site_ids=cra_scope[0] if cra_scope else None,
AdverseEvent,
*ae_scope,
AdverseEvent.report_due_date.is_not(None),
AdverseEvent.report_due_date < today,
)
due_soon_aes, due_soon_ae_due = await _count_and_earliest(
db,
AdverseEvent,
*ae_scope,
AdverseEvent.report_due_date >= today,
AdverseEvent.report_due_date <= due_soon_date,
)
overdue_aes = len(items)
await notification_service.sync_aggregate_notification(
db,
study_id=study_id,
recipient_id=user.id,
category="RISK_OVERDUE_AE",
priority="HIGH",
priority="URGENT",
title="逾期 AE 待处理",
message=f"当前有 {overdue_aes} 条逾期 AE 需要跟进",
action_path="/risk-issues/sae",
source_type="RISK_OVERDUE_AE",
source_id=str(study_id),
count=overdue_aes if can_read_aes else 0,
count=int(overdue_aes) if can_read_aes else 0,
due_at=_date_due_at(overdue_ae_due),
)
await notification_service.sync_aggregate_notification(
db,
study_id=study_id,
recipient_id=user.id,
category="RISK_AE_DUE_SOON",
priority="HIGH",
title="AE 上报时限临近",
message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_aes} 条 AE 到达上报时限",
action_path="/risk-issues/sae",
source_type="RISK_AE_DUE_SOON",
source_id=str(study_id),
count=int(due_soon_aes) if can_read_aes else 0,
due_at=_date_due_at(due_soon_ae_due),
)
monitoring_scope = [
MonitoringVisitIssue.study_id == study_id,
MonitoringVisitIssue.status == "OPEN",
MonitoringVisitIssue.due_at.is_not(None),
]
if site_ids is not None:
monitoring_scope.append(MonitoringVisitIssue.site_id.in_(site_ids))
overdue_monitoring = 0
overdue_monitoring_due = None
due_soon_monitoring = 0
due_soon_monitoring_due = None
if can_read_monitoring:
overdue_monitoring = len(await monitoring_issue_crud.list_issues(
overdue_monitoring, overdue_monitoring_due = await _count_and_earliest(
db,
study_id,
overdue=True,
limit=2000,
))
MonitoringVisitIssue,
*monitoring_scope,
MonitoringVisitIssue.due_at < current_time,
)
due_soon_monitoring, due_soon_monitoring_due = await _count_and_earliest(
db,
MonitoringVisitIssue,
*monitoring_scope,
MonitoringVisitIssue.due_at >= current_time,
MonitoringVisitIssue.due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS),
)
await notification_service.sync_aggregate_notification(
db,
study_id=study_id,
@@ -67,6 +158,384 @@ async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) ->
action_path="/risk-issues/monitoring-visits",
source_type="RISK_OVERDUE_MONITORING",
source_id=str(study_id),
count=overdue_monitoring if can_read_monitoring else 0,
count=int(overdue_monitoring) if can_read_monitoring else 0,
due_at=_as_utc(overdue_monitoring_due),
)
await notification_service.sync_aggregate_notification(
db,
study_id=study_id,
recipient_id=user.id,
category="RISK_MONITORING_DUE_SOON",
priority="HIGH",
title="监查问题整改时限临近",
message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_monitoring} 条监查问题到期",
action_path="/risk-issues/monitoring-visits",
source_type="RISK_MONITORING_DUE_SOON",
source_id=str(study_id),
count=int(due_soon_monitoring) if can_read_monitoring else 0,
due_at=_as_utc(due_soon_monitoring_due),
)
async def _sync_document_distribution_reminders(
db: AsyncSession,
*,
study_id: uuid.UUID,
user,
role: str,
current_time: datetime,
) -> None:
can_read_documents = is_system_admin(user) or await role_has_api_permission(
db, study_id, role, "documents:read"
)
if not can_read_documents:
await notification_service.resolve_missing_recipient_sources(
db,
study_id=study_id,
recipient_id=user.id,
source_type="DOCUMENT_DISTRIBUTION",
active_source_ids=set(),
)
return
site_ids = await site_crud.list_ids_by_contact_user(db, study_id, user.id)
target_filters = [
and_(
Distribution.target_type == DistributionTargetType.USER,
Distribution.target_id == str(user.id),
),
and_(
Distribution.target_type == DistributionTargetType.ROLE,
Distribution.target_id == role,
),
]
if site_ids:
target_filters.append(and_(
Distribution.target_type == DistributionTargetType.SITE,
Distribution.target_id.in_({str(site_id) for site_id in site_ids}),
))
rows = (await db.execute(
select(Distribution, Document, DocumentVersion)
.join(Document, Distribution.document_id == Document.id)
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
.outerjoin(
Acknowledgement,
and_(
Acknowledgement.distribution_id == Distribution.id,
Acknowledgement.user_id == user.id,
Acknowledgement.ack_type == AcknowledgementType.RECEIVED,
),
)
.where(
Document.trial_id == study_id,
Document.status == DocumentStatus.ACTIVE,
Distribution.status == DistributionStatus.ACTIVE,
Acknowledgement.id.is_(None),
or_(*target_filters),
)
)).all()
active_source_ids: set[str] = set()
for distribution, document, version in rows:
source_id = str(distribution.id)
active_source_ids.add(source_id)
distribution_due_at = _as_utc(distribution.due_at)
if distribution_due_at and distribution_due_at < current_time:
stage = "OVERDUE"
category = "DOCUMENT_ACK_OVERDUE"
priority = "URGENT"
title = "文件回执已逾期"
elif distribution_due_at and distribution_due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS):
stage = "DUE_SOON"
category = "DOCUMENT_ACK_DUE_SOON"
priority = "HIGH"
title = "文件回执即将到期"
else:
stage = "PENDING"
category = "DOCUMENT_DISTRIBUTION"
priority = "NORMAL"
title = "有新的文件版本待接收"
due_version = distribution_due_at.isoformat() if distribution_due_at else "none"
await notification_service.sync_state_notification(
db,
study_id=study_id,
recipient_id=user.id,
category=category,
priority=priority,
title=title,
message=f"{document.title}{version.version_no} 已分发,请完成接收回执",
action_path=f"/documents/{document.id}",
source_type="DOCUMENT_DISTRIBUTION",
source_id=source_id,
source_version=f"{stage}:{due_version}",
active=True,
due_at=distribution_due_at,
)
await notification_service.resolve_missing_recipient_sources(
db,
study_id=study_id,
recipient_id=user.id,
source_type="DOCUMENT_DISTRIBUTION",
active_source_ids=active_source_ids,
)
async def _sync_milestone_reminders(
db: AsyncSession,
*,
study_id: uuid.UUID,
user,
role: str,
current_time: datetime,
) -> None:
can_read = is_system_admin(user) or await role_has_api_permission(
db, study_id, role, "project_milestones:read"
)
active_source_ids: set[str] = set()
if can_read:
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
due_soon_date = today + timedelta(days=MILESTONE_DUE_SOON_DAYS)
milestones = (await db.scalars(
select(Milestone).where(
Milestone.study_id == study_id,
Milestone.owner_id == user.id,
Milestone.status != "DONE",
)
)).all()
for milestone in milestones:
due_date = milestone.adjusted_end_date or milestone.planned_date
if due_date is None or due_date > due_soon_date:
continue
source_id = str(milestone.id)
active_source_ids.add(source_id)
overdue = due_date < today
await notification_service.sync_state_notification(
db,
study_id=study_id,
recipient_id=user.id,
category="MILESTONE_OVERDUE" if overdue else "MILESTONE_DUE_SOON",
priority="HIGH" if overdue else "NORMAL",
title="项目里程碑已逾期" if overdue else "项目里程碑即将到期",
message=f"{milestone.name}”计划日期为 {due_date.isoformat()}",
action_path="/project/milestones",
source_type="PROJECT_MILESTONE",
source_id=source_id,
source_version=f"{'OVERDUE' if overdue else 'DUE_SOON'}:{due_date.isoformat()}:{milestone.status}",
active=True,
due_at=_date_due_at(due_date),
)
await notification_service.resolve_missing_recipient_sources(
db,
study_id=study_id,
recipient_id=user.id,
source_type="PROJECT_MILESTONE",
active_source_ids=active_source_ids,
)
async def _sync_visit_window_reminders(
db: AsyncSession,
*,
study_id: uuid.UUID,
user,
role: str,
current_time: datetime,
) -> None:
"""Project active visit windows to the members who can actually maintain them."""
can_manage_visits = is_system_admin(user) or await role_has_api_permission(
db, study_id, role, "visits:update"
)
active_source_ids: set[str] = set()
if can_manage_visits:
cra_scope = await get_cra_site_scope(db, study_id, user)
site_ids = cra_scope[0] if cra_scope else None
filters = [
Visit.study_id == study_id,
Visit.actual_date.is_(None),
Visit.status.not_in(["DONE", "CANCELLED"]),
Subject.status.not_in(["COMPLETED", "DROPPED"]),
Site.is_active.is_(True),
]
if site_ids is not None:
filters.append(Subject.site_id.in_(site_ids))
rows = (await db.execute(
select(Visit, Subject)
.join(Subject, Subject.id == Visit.subject_id)
.join(Site, Site.id == Subject.site_id)
.where(*filters)
)).all()
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
due_soon_date = today + timedelta(days=VISIT_WINDOW_DUE_SOON_DAYS)
for visit, subject in rows:
window_start = visit.window_start or visit.planned_date
window_end = visit.window_end or visit.planned_date
if window_start is None or window_end is None or window_start > window_end:
continue
if window_end < today:
category = "VISIT_WINDOW_MISSED"
priority = "HIGH"
title = "受试者访视已错过窗口"
message = "一个受试者访视已错过计划窗口,请进入详情核对并处理"
stage = "MISSED"
elif window_start <= due_soon_date:
category = "VISIT_WINDOW_DUE_SOON"
priority = "NORMAL"
title = "受试者访视窗口临近"
message = "一个受试者访视已进入近期窗口,请及时核对访视安排"
stage = "DUE_SOON"
else:
continue
source_id = str(visit.id)
active_source_ids.add(source_id)
await notification_service.sync_state_notification(
db,
study_id=study_id,
recipient_id=user.id,
category=category,
priority=priority,
title=title,
message=message,
action_path=f"/subjects/{subject.id}",
source_type="SUBJECT_VISIT_WINDOW",
source_id=source_id,
source_version=f"{stage}:{window_start.isoformat()}:{window_end.isoformat()}",
active=True,
due_at=_date_due_at(window_end),
)
await notification_service.resolve_missing_recipient_sources(
db,
study_id=study_id,
recipient_id=user.id,
source_type="SUBJECT_VISIT_WINDOW",
active_source_ids=active_source_ids,
)
async def _sync_collaboration_edit_request_reminders(
db: AsyncSession,
*,
study_id: uuid.UUID,
user,
role: str,
) -> None:
can_read = is_system_admin(user) or await role_has_api_permission(
db, study_id, role, "collaboration:read"
)
if not can_read:
await notification_service.resolve_missing_recipient_sources(
db,
study_id=study_id,
recipient_id=user.id,
source_type="COLLABORATION_EDIT_REQUEST",
active_source_ids=set(),
)
return
manages_file = or_(
CollaborationFile.owner_id == user.id,
select(CollaborationMember.id).where(
CollaborationMember.file_id == CollaborationFile.id,
CollaborationMember.user_id == user.id,
CollaborationMember.role == "MANAGER",
).exists(),
)
rows = (await db.execute(
select(CollaborationEditRequest, CollaborationFile, User)
.join(CollaborationFile, CollaborationFile.id == CollaborationEditRequest.file_id)
.join(User, User.id == CollaborationEditRequest.requester_id)
.where(
CollaborationFile.study_id == study_id,
CollaborationFile.status == "ACTIVE",
CollaborationFile.deleted_at.is_(None),
CollaborationEditRequest.status == "PENDING",
manages_file,
)
)).all()
active_source_ids: set[str] = set()
for request, item, requester in rows:
source_id = str(request.id)
active_source_ids.add(source_id)
requester_name = str(requester.full_name or requester.email or "项目成员")
await notification_service.sync_state_notification(
db,
study_id=study_id,
recipient_id=user.id,
category="COLLABORATION_EDIT_REQUEST",
priority="NORMAL",
title="新的编辑权限申请",
message=f"{requester_name} 申请编辑“{item.title}",
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
source_type="COLLABORATION_EDIT_REQUEST",
source_id=source_id,
source_version="PENDING",
active=True,
dedupe_key=f"collaboration-edit-request:{request.id}",
)
await notification_service.resolve_missing_recipient_sources(
db,
study_id=study_id,
recipient_id=user.id,
source_type="COLLABORATION_EDIT_REQUEST",
active_source_ids=active_source_ids,
)
async def sync_project_reminders(
db: AsyncSession,
study_id: uuid.UUID,
user,
*,
now: datetime | None = None,
) -> None:
membership = await member_crud.get_member(db, study_id, user.id)
if not membership or not membership.is_active:
await notification_service.resolve_recipient_study_notifications(
db,
study_id=study_id,
recipient_id=user.id,
)
await db.commit()
return
role = membership.role_in_study
current_time = now or datetime.now(timezone.utc)
if current_time.tzinfo is None:
current_time = current_time.replace(tzinfo=timezone.utc)
else:
current_time = current_time.astimezone(timezone.utc)
await _sync_risk_reminders(
db,
study_id=study_id,
user=user,
role=role,
current_time=current_time,
)
await _sync_document_distribution_reminders(
db,
study_id=study_id,
user=user,
role=role,
current_time=current_time,
)
await _sync_milestone_reminders(
db,
study_id=study_id,
user=user,
role=role,
current_time=current_time,
)
await _sync_visit_window_reminders(
db,
study_id=study_id,
user=user,
role=role,
current_time=current_time,
)
await _sync_collaboration_edit_request_reminders(
db,
study_id=study_id,
user=user,
role=role,
)
await db.commit()
+6 -4
View File
@@ -195,7 +195,7 @@ async def get_login_summaries(
"last_login_at": row.login_at,
"last_seen_at": row.last_seen_at,
"client_type": row.client_type,
"active_session_count": 0,
"active_sources": set(),
},
)
row_last_seen_at = _as_utc(row.last_seen_at)
@@ -204,14 +204,16 @@ async def get_login_summaries(
):
current["last_seen_at"] = row_last_seen_at
if row.ended_at is None and row_last_seen_at >= cutoff:
current["active_session_count"] += 1
source_key = (row.client_type, row.login_ip) if row.login_ip else ("session", row.id)
current["active_sources"].add(source_key)
for user_id, item in mutable.items():
active_session_count = len(item["active_sources"])
summaries[user_id] = UserLoginSummary(
status="ONLINE" if item["active_session_count"] else "OFFLINE",
status="ONLINE" if active_session_count else "OFFLINE",
last_login_at=item["last_login_at"],
last_seen_at=item["last_seen_at"],
client_type=item["client_type"],
active_session_count=item["active_session_count"],
active_session_count=active_session_count,
)
return summaries
+22 -18
View File
@@ -674,24 +674,30 @@ def test_subject_audit_uses_structured_business_snapshot():
assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source
def test_faq_audit_uses_business_descriptions():
"""医学咨询审计详情应使用业务描述,避免 FAQ 技术文案"""
def test_shared_library_does_not_write_audits():
"""共享库各模块及其附件不应写入业务审计"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
category_source = (api_dir / "faq_categories.py").read_text()
item_source = (api_dir / "faqs.py").read_text()
services_dir = Path(__file__).resolve().parents[1] / "app" / "services"
non_audited_sources = [
(api_dir / "faq_categories.py").read_text(),
(api_dir / "faqs.py").read_text(),
(api_dir / "precautions.py").read_text(),
(services_dir / "collaboration_service.py").read_text(),
(services_dir / "collaboration_share_service.py").read_text(),
(services_dir / "onlyoffice_collaboration_service.py").read_text(),
]
assert 'description": f"创建“{category.name}”分类"' in category_source
assert 'description": f"更新“{updated.name}”分类"' in category_source
assert 'description": f"删除“{category_name}”分类"' in category_source
assert 'f"创建医学咨询问题“{question_name}"' in item_source
assert 'f"更新医学咨询问题“{question_name}"' in item_source
assert 'f"回复医学咨询问题“{question_name}"' in item_source
assert 'f"删除医学咨询问题“{question_name}"' in item_source
assert 'f"删除医学咨询问题“{question_name}”的回复"' in item_source
assert "FAQ 分类 {category.name} 已创建" not in category_source
assert 'detail="FAQ 已创建"' not in item_source
assert 'detail = "FAQ updated"' not in item_source
assert '"description": "创建医学咨询问题"' not in item_source
for source in non_audited_sources:
assert "audit_crud" not in source
assert "._audit(" not in source
attachment_source = (api_dir / "attachments.py").read_text()
assert 'SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}' in attachment_source
assert "if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
assert "if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
onlyoffice_source = (api_dir / "onlyoffice.py").read_text()
assert "OFFICE_PREVIEW_OPEN" not in onlyoffice_source
def test_domain_audits_use_business_object_names():
@@ -704,7 +710,6 @@ def test_domain_audits_use_business_object_names():
"material_equipments.py",
"visits.py",
"aes.py",
"precautions.py",
"fees_contracts.py",
"startup.py",
"subject_histories.py",
@@ -715,7 +720,6 @@ def test_domain_audits_use_business_object_names():
assert "_equipment_audit_detail" in sources["material_equipments.py"]
assert "_visit_audit_detail" in sources["visits.py"]
assert "_ae_audit_detail" in sources["aes.py"]
assert "_precaution_audit_detail" in sources["precautions.py"]
assert "_contract_audit_detail" in sources["fees_contracts.py"]
assert "_payment_audit_detail" in sources["fees_contracts.py"]
assert "_feasibility_audit_detail" in sources["startup.py"]
+19 -27
View File
@@ -140,7 +140,6 @@ async def test_copy_revision_saves_to_the_selected_workspace_folder(monkeypatch,
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@@ -159,22 +158,13 @@ async def test_delete_revision_soft_deletes_a_non_current_version(monkeypatch):
)
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
require_manager = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(collaboration_service, "require_file_manager", require_manager)
monkeypatch.setattr(collaboration_service, "_audit", audit)
await collaboration_service.delete_revision(db, item, revision_id, user)
require_manager.assert_awaited_once_with(db, item, user)
assert revision.deleted_at is not None
assert revision.deleted_by == user.id
audit.assert_awaited_once_with(
db,
item,
"COLLABORATION_REVISION_DELETED",
user,
{"revision_id": str(revision_id), "revision_no": 2},
)
db.commit.assert_awaited_once()
@@ -260,7 +250,6 @@ async def test_sheet_permission_change_updates_current_file_without_creating_rev
user = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock(), refresh=AsyncMock())
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
persist_revision = AsyncMock()
monkeypatch.setattr(collaboration_service, "_persist_revision_bytes", persist_revision)
@@ -540,7 +529,6 @@ async def test_share_link_url_stays_immutable_when_disabled_and_reenabled(monkey
refresh=AsyncMock(),
)
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
original_token = collaboration_share_service.share_token(link)
await collaboration_share_service.update_share_link(
@@ -803,12 +791,11 @@ async def test_copy_creates_an_independent_r1_from_the_current_revision(monkeypa
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@pytest.mark.asyncio
async def test_download_returns_current_revision_and_records_audit(monkeypatch, tmp_path):
async def test_download_returns_current_revision_without_writing_audit(monkeypatch, tmp_path):
source = tmp_path / "source.docx"
source.write_bytes(collaboration_service.blank_file_bytes("word"))
revision = SimpleNamespace(
@@ -821,22 +808,33 @@ async def test_download_returns_current_revision_and_records_audit(monkeypatch,
user = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
require_exporter = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter)
monkeypatch.setattr(collaboration_service, "_audit", audit)
result = await collaboration_service.prepare_download(db, item, user)
assert result is revision
require_exporter.assert_awaited_once_with(db, item, user)
audit.assert_awaited_once_with(
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_revision_preview_does_not_write_passive_audit(monkeypatch, tmp_path):
revision_path = tmp_path / "revision.docx"
revision_path.write_bytes(b"office")
revision = SimpleNamespace(id=uuid.uuid4(), file_uri=str(revision_path), revision_no=2)
item = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(commit=AsyncMock())
monkeypatch.setattr(collaboration_service, "get_revision_or_404", AsyncMock(return_value=revision))
result = await collaboration_service.prepare_revision_preview(
db,
item,
"COLLABORATION_FILE_DOWNLOADED",
user,
{"revision_id": str(revision.id), "revision_no": 3},
revision.id,
SimpleNamespace(id=uuid.uuid4()),
)
db.commit.assert_awaited_once()
assert result is revision
db.commit.assert_not_awaited()
@pytest.mark.asyncio
@@ -1041,12 +1039,6 @@ async def test_force_save_updates_session_recovery_revision(monkeypatch):
"append_revision",
AsyncMock(return_value=(revision, True)),
)
monkeypatch.setattr(
onlyoffice_collaboration_service.collaboration_service,
"_audit",
AsyncMock(),
)
result = await onlyoffice_collaboration_service.process_callback(
db,
session.id,
+248 -14
View File
@@ -1,19 +1,24 @@
import uuid
from datetime import datetime, timezone
from datetime import date, datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from app.api.v1 import visits as visits_api
from app.models.notification import Notification
from app.models.desktop_notification import DesktopNotificationDelivery
from app.models.collaboration import CollaborationEditRequest
from app.services import collaboration_service, notification_service, project_reminder_service
from app.services import collaboration_service, desktop_notification_service, notification_service, project_reminder_service
def test_generic_notification_table_has_recipient_dedupe_and_state_indexes():
table = Notification.__table__
assert table.name == "notifications"
assert {"study_id", "recipient_id", "category", "action_path", "source_type", "source_id", "read_at", "resolved_at"} <= set(table.columns.keys())
assert {
"study_id", "recipient_id", "category", "action_path", "source_type", "source_id",
"requires_action", "due_at", "read_at", "resolved_at",
} <= set(table.columns.keys())
assert any(constraint.name == "uq_notifications_recipient_dedupe" for constraint in table.constraints)
assert {index.name for index in table.indexes} >= {
"ix_notifications_recipient_study_state",
@@ -21,6 +26,23 @@ def test_generic_notification_table_has_recipient_dedupe_and_state_indexes():
}
def test_desktop_delivery_targets_the_generic_notification_feed():
table = DesktopNotificationDelivery.__table__
assert table.columns.notification_id.nullable is True
assert any(
constraint.name == "uq_desktop_notification_user_notification"
for constraint in table.constraints
)
statement = str(desktop_notification_service._eligible_query(
uuid.uuid4(),
datetime.now(timezone.utc),
datetime.now(timezone.utc),
))
assert "notifications" in statement
assert "notification_id" in statement
assert "resolved_at" in statement
@pytest.mark.asyncio
async def test_recipient_notification_creation_is_deduplicated_per_recipient():
existing_id = uuid.uuid4()
@@ -64,24 +86,237 @@ async def test_resolving_a_source_closes_every_recipient_notification():
@pytest.mark.asyncio
async def test_project_risks_are_materialized_into_the_generic_notification_table(monkeypatch):
async def test_project_reminder_sync_runs_every_supported_rule_group(monkeypatch):
study_id = uuid.uuid4()
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
membership = SimpleNamespace(is_active=True, role_in_study="PM")
db = SimpleNamespace(commit=AsyncMock())
sync = AsyncMock()
risk_sync = AsyncMock()
distribution_sync = AsyncMock()
milestone_sync = AsyncMock()
visit_sync = AsyncMock()
collaboration_sync = AsyncMock()
monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=membership))
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None))
monkeypatch.setattr(project_reminder_service.ae_crud, "list_ae", AsyncMock(return_value=[object(), object()]))
monkeypatch.setattr(project_reminder_service.monitoring_issue_crud, "list_issues", AsyncMock(return_value=[object()]))
monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync)
monkeypatch.setattr(project_reminder_service, "_sync_risk_reminders", risk_sync)
monkeypatch.setattr(project_reminder_service, "_sync_document_distribution_reminders", distribution_sync)
monkeypatch.setattr(project_reminder_service, "_sync_milestone_reminders", milestone_sync)
monkeypatch.setattr(project_reminder_service, "_sync_visit_window_reminders", visit_sync)
monkeypatch.setattr(project_reminder_service, "_sync_collaboration_edit_request_reminders", collaboration_sync)
await project_reminder_service.sync_project_reminders(db, study_id, user)
assert sync.await_count == 2
assert sync.await_args_list[0].kwargs["count"] == 2
assert sync.await_args_list[1].kwargs["count"] == 1
risk_sync.assert_awaited_once()
distribution_sync.assert_awaited_once()
milestone_sync.assert_awaited_once()
visit_sync.assert_awaited_once()
collaboration_sync.assert_awaited_once()
assert collaboration_sync.await_args.kwargs["role"] == "PM"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_inactive_membership_resolves_stale_project_notifications(monkeypatch):
db = SimpleNamespace(commit=AsyncMock())
resolve = AsyncMock()
user = SimpleNamespace(id=uuid.uuid4(), is_admin=True)
study_id = uuid.uuid4()
monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=None))
monkeypatch.setattr(
project_reminder_service.notification_service,
"resolve_recipient_study_notifications",
resolve,
)
await project_reminder_service.sync_project_reminders(db, study_id, user)
resolve.assert_awaited_once_with(db, study_id=study_id, recipient_id=user.id)
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_project_risks_include_due_soon_and_overdue_aggregates(monkeypatch):
study_id = uuid.uuid4()
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
now = datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc)
db = SimpleNamespace()
sync = AsyncMock()
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None))
monkeypatch.setattr(
project_reminder_service,
"_count_and_earliest",
AsyncMock(side_effect=[
(2, date(2026, 7, 15)),
(1, date(2026, 7, 18)),
(3, now - timedelta(hours=2)),
(4, now + timedelta(days=1)),
]),
)
monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync)
await project_reminder_service._sync_risk_reminders(
db,
study_id=study_id,
user=user,
role="PM",
current_time=now,
)
assert [call.kwargs["category"] for call in sync.await_args_list] == [
"RISK_OVERDUE_AE",
"RISK_AE_DUE_SOON",
"RISK_OVERDUE_MONITORING",
"RISK_MONITORING_DUE_SOON",
]
assert [call.kwargs["count"] for call in sync.await_args_list] == [2, 1, 3, 4]
@pytest.mark.asyncio
async def test_visit_window_reminders_are_actionable_scoped_and_privacy_safe(monkeypatch):
study_id = uuid.uuid4()
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
site_id = uuid.uuid4()
due_visit_id = uuid.uuid4()
missed_visit_id = uuid.uuid4()
due_subject_id = uuid.uuid4()
missed_subject_id = uuid.uuid4()
due_visit = SimpleNamespace(
id=due_visit_id,
planned_date=date(2026, 7, 19),
window_start=date(2026, 7, 18),
window_end=date(2026, 7, 20),
)
missed_visit = SimpleNamespace(
id=missed_visit_id,
planned_date=date(2026, 7, 14),
window_start=date(2026, 7, 13),
window_end=date(2026, 7, 15),
)
due_subject = SimpleNamespace(id=due_subject_id, subject_no="S-PRIVACY-001")
missed_subject = SimpleNamespace(id=missed_subject_id, subject_no="S-PRIVACY-002")
rows = SimpleNamespace(all=lambda: [
(due_visit, due_subject),
(missed_visit, missed_subject),
])
db = SimpleNamespace(execute=AsyncMock(return_value=rows))
sync = AsyncMock()
resolve = AsyncMock()
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
monkeypatch.setattr(
project_reminder_service,
"get_cra_site_scope",
AsyncMock(return_value=({site_id}, {"研究中心"})),
)
monkeypatch.setattr(project_reminder_service.notification_service, "sync_state_notification", sync)
monkeypatch.setattr(
project_reminder_service.notification_service,
"resolve_missing_recipient_sources",
resolve,
)
await project_reminder_service._sync_visit_window_reminders(
db,
study_id=study_id,
user=user,
role="CRA",
current_time=datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc),
)
assert [call.kwargs["category"] for call in sync.await_args_list] == [
"VISIT_WINDOW_DUE_SOON",
"VISIT_WINDOW_MISSED",
]
assert [call.kwargs["priority"] for call in sync.await_args_list] == ["NORMAL", "HIGH"]
assert sync.await_args_list[0].kwargs["action_path"] == f"/subjects/{due_subject_id}"
assert sync.await_args_list[1].kwargs["action_path"] == f"/subjects/{missed_subject_id}"
messages = " ".join(call.kwargs["message"] for call in sync.await_args_list)
assert "S-PRIVACY-001" not in messages
assert "S-PRIVACY-002" not in messages
resolve.assert_awaited_once_with(
db,
study_id=study_id,
recipient_id=user.id,
source_type="SUBJECT_VISIT_WINDOW",
active_source_ids={str(due_visit_id), str(missed_visit_id)},
)
@pytest.mark.asyncio
async def test_visit_window_reminders_resolve_when_recipient_cannot_manage_visits(monkeypatch):
study_id = uuid.uuid4()
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
db = SimpleNamespace(execute=AsyncMock())
resolve = AsyncMock()
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=False))
monkeypatch.setattr(
project_reminder_service.notification_service,
"resolve_missing_recipient_sources",
resolve,
)
await project_reminder_service._sync_visit_window_reminders(
db,
study_id=study_id,
user=user,
role="QA",
current_time=datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc),
)
db.execute.assert_not_awaited()
resolve.assert_awaited_once_with(
db,
study_id=study_id,
recipient_id=user.id,
source_type="SUBJECT_VISIT_WINDOW",
active_source_ids=set(),
)
@pytest.mark.asyncio
async def test_completed_visit_closes_every_recipient_reminder_immediately(monkeypatch):
first_id = uuid.uuid4()
second_id = uuid.uuid4()
db = SimpleNamespace(commit=AsyncMock())
resolve = AsyncMock()
monkeypatch.setattr(visits_api.notification_service, "resolve_source_notifications", resolve)
await visits_api._resolve_visit_reminders(db, [first_id, second_id, first_id])
assert resolve.await_count == 2
assert {call.kwargs["source_id"] for call in resolve.await_args_list} == {
str(first_id),
str(second_id),
}
assert all(
call.kwargs["source_type"] == "SUBJECT_VISIT_WINDOW"
for call in resolve.await_args_list
)
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_reading_an_informational_notification_archives_it():
item = SimpleNamespace(
id=uuid.uuid4(),
read_at=None,
resolved_at=None,
requires_action=False,
)
db = SimpleNamespace(
scalar=AsyncMock(return_value=item),
commit=AsyncMock(),
refresh=AsyncMock(),
)
result = await notification_service.mark_read(
db,
study_id=uuid.uuid4(),
recipient_id=uuid.uuid4(),
notification_id=item.id,
)
assert result.read_at is not None
assert result.resolved_at == result.read_at
db.commit.assert_awaited_once()
@@ -128,7 +363,6 @@ async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypa
"get_member",
AsyncMock(return_value=SimpleNamespace(is_active=True)),
)
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify)
result = await collaboration_service.create_edit_request(db, item, user)
-4
View File
@@ -39,12 +39,10 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
permission = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock())
monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission)
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", audit)
response = Response()
db = object()
@@ -54,7 +52,6 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
assert result.file_name == "研究方案最终版.DOCX"
assert result.config["document"]["title"] == "研究方案最终版.DOCX"
assert response.headers["Cache-Control"] == "no-store"
audit.assert_awaited_once()
@pytest.mark.asyncio
@@ -96,7 +93,6 @@ async def test_version_config_reuses_document_access_and_version_hash(monkeypatc
monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document))
monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access)
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", AsyncMock())
result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user)
+49
View File
@@ -134,6 +134,55 @@ async def test_login_summary_marks_recent_unended_sessions_online(db_session, mo
assert summaries[user.id].client_type == "desktop"
@pytest.mark.asyncio
async def test_login_summary_counts_same_client_and_ip_as_one_active_source(db_session, monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
user = User(
id=uuid.uuid4(),
email="deduplicated-login-source@example.com",
password_hash="hash",
full_name="Deduplicated Login Source",
clinical_department="IT",
status=UserStatus.ACTIVE,
)
db_session.add(user)
db_session.add_all(
[
UserLoginSession(
id=uuid.uuid4(),
user_id=user.id,
client_type="web",
login_ip="192.168.97.1",
login_at=now - timedelta(minutes=2),
last_seen_at=now - timedelta(seconds=20),
),
UserLoginSession(
id=uuid.uuid4(),
user_id=user.id,
client_type="web",
login_ip="192.168.97.1",
login_at=now - timedelta(minutes=1),
last_seen_at=now - timedelta(seconds=10),
),
UserLoginSession(
id=uuid.uuid4(),
user_id=user.id,
client_type="desktop",
login_ip="192.168.97.1",
login_at=now - timedelta(minutes=1),
last_seen_at=now - timedelta(seconds=5),
),
]
)
await db_session.commit()
summaries = await get_login_summaries(db_session, [user.id])
assert summaries[user.id].status == "ONLINE"
assert summaries[user.id].active_session_count == 2
@pytest.mark.asyncio
async def test_user_list_filters_online_and_offline_accounts(db_session, monkeypatch):
now = datetime.now(timezone.utc)
+5 -5
View File
@@ -38,6 +38,7 @@ services:
MONITORING_RETENTION_INTERVAL_SECONDS: ${MONITORING_RETENTION_INTERVAL_SECONDS:-86400}
USER_LOGIN_ACTIVITY_RETENTION_DAYS: ${USER_LOGIN_ACTIVITY_RETENTION_DAYS:-180}
USER_SESSION_ONLINE_SECONDS: ${USER_SESSION_ONLINE_SECONDS:-300}
NOTIFICATION_SYNC_INTERVAL_SECONDS: ${NOTIFICATION_SYNC_INTERVAL_SECONDS:-300}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32,::1/128,172.16.0.0/12,192.168.0.0/16}
MONITORING_SERVER_PUBLIC_IP: ${MONITORING_SERVER_PUBLIC_IP:-}
MONITORING_PUBLIC_IP_DISCOVERY_URLS: ${MONITORING_PUBLIC_IP_DISCOVERY_URLS:-https://api64.ipify.org,https://icanhazip.com}
@@ -46,11 +47,11 @@ services:
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: ${MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS:-2.5}
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: ${MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS:-604800}
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: ${MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS:-10}
ONLYOFFICE_ENABLED: ${ONLYOFFICE_ENABLED:-false}
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-}
ONLYOFFICE_ENABLED: ${ONLYOFFICE_ENABLED:-true}
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?请先运行安装脚本生成 ONLYOFFICE_JWT_SECRET}
ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice}
ONLYOFFICE_STORAGE_BASE_URL: ${ONLYOFFICE_STORAGE_BASE_URL:-http://backend:8000}
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:-}
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:?请先运行安装脚本生成 ONLYOFFICE_INSTANCE_ID}
ONLYOFFICE_CONFIG_TTL_SECONDS: ${ONLYOFFICE_CONFIG_TTL_SECONDS:-300}
COLLABORATION_MAX_FILE_BYTES: ${COLLABORATION_MAX_FILE_BYTES:-52428800}
depends_on:
@@ -81,10 +82,9 @@ services:
image: ctms-onlyoffice:9.4.0.1
container_name: ctms_onlyoffice
restart: always
profiles: ["office"]
environment:
JWT_ENABLED: "true"
JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-}
JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?请先运行安装脚本生成 ONLYOFFICE_JWT_SECRET}
JWT_HEADER: AuthorizationJwt
JWT_IN_BODY: "false"
EXAMPLE_ENABLED: "false"
@@ -1,8 +1,8 @@
# CTMS Desktop Release Stabilization Checklist
状态: `active`
适用范围: WebmacOS Desktop 统一客户端发布
最后更新: `2026-07-14`
适用范围: WebmacOS Desktop 与 Windows x64 Desktop 统一客户端发布
最后更新: `2026-07-17`
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。当前允许按 `docs/desktop-local-cache-plan.md` 引入在线辅助本地缓存,但不引入离线登录、离线写入、本地业务权威数据、内嵌后端服务或离线同步。
@@ -28,12 +28,14 @@ npm run desktop:build:app
- [ ] `frontend/package.json``package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。
- [ ] `VITE_BUILD_CHANNEL=release``VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。
- [ ] 在正式 release tag 和签名环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址齐备。
- [ ] macOS app 已签名和公证
- [ ] 在正式 release tag 环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、当前精确版本的平台签名策略、updater 私钥和生产 artifact HTTPS 基址齐备。
- [ ] macOS app 已完成 Apple 签名/公证;若当前精确版本获批平台签名例外,则已验证 `Signature=adhoc`、确认未公证并附带 Gatekeeper 风险说明
- [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 返回 `Valid`;若当前精确版本获批例外,则应用和安装器必须返回 `NotSigned` 并附带 SmartScreen 风险说明。
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。
- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY``TAURI_SIGNING_PRIVATE_KEY_PASSWORD`Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build:macos-release -- --ci`
- [ ] 正式 updater feed 先执行 `npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>` 生成 `latest.json``SHA256SUMS.txt`
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir>`,并确认 checksum manifest、updater artifact、`.sig``latest.json` 均通过校验
- [ ] 始终设置 `TAURI_SIGNING_PRIVATE_KEY``TAURI_SIGNING_PRIVATE_KEY_PASSWORD``REQUIRE_UPDATER_SIGNING=true`;默认 macOS 路径再设置 Apple 变量和 `REQUIRE_DESKTOP_SIGNING=true`,例外路径则设置 `DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception``ALLOW_UNSIGNED_PLATFORM_RELEASE=true`执行 `npm run desktop:build:macos-unsigned-release -- --ci`
- [ ] 默认 Windows 路径设置 PFX、密码、`WINDOWS_TIMESTAMP_URL` `REQUIRE_WINDOWS_SIGNING=true`;例外路径不得伪装签名状态,构建后必须对应用和安装器验证 `NotSigned`
- [ ] 正式 updater feed 使用 `--artifact <CTMS.app.tar.gz>``--platform-artifact windows-x86_64=<CTMS.nsis.zip>` 汇总生成同一个 `latest.json``SHA256SUMS.txt`
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`,并确认两个平台的 updater artifact、`.sig`、安装包、provenance、checksum manifest 和 `latest.json` 均通过校验。
- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
@@ -68,7 +70,7 @@ npm run desktop:build:app
- [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。
- [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。
- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。
- [ ] signed macOS release candidate workflow 只允许从 `vX.Y.Z` tag 运行,并包含签名环境检查、Universal macOS 构建、update feed 生成、checksum 校验和 verified release directory 上传
- [ ] Desktop release candidate workflow 只允许从 `vX.Y.Z` tag 运行;先按精确版本解析平台签名策略,macOS 和 Windows 原生 job 分别验证实际签名状态,再由聚合 job 生成包含 `darwin-aarch64``darwin-x86_64``windows-x86_64` 的 feed、provenance、checksum 清单和 verified release directory。
人工复审还必须确认:
@@ -86,7 +88,8 @@ npm run desktop:build:app
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 构建默认服务器地址已注入 | 不适用 | 必测 | 首次启动使用 `VITE_DESKTOP_SERVER_URL`,不要求重复输入;运行时代码不含生产域名 |
| 构建默认地址和本地配置均缺失 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
| 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 |
@@ -261,3 +264,35 @@ npm run desktop:build:app
真实 `.app` 仍需人工验证:红色按钮真正关闭主窗口后 Dock 重建、菜单顺序、个人中心自身关闭按钮、编辑菜单文本输入行为、自定义快捷键即时同步,以及系统明暗模式切换时的标题栏一致性。
本轮未引入以下条件性 Swift 能力:Quick Look 需先确认附件审阅频率和 Windows 降级语义;Touch ID 会改变现有 30 天会话恢复体验,需先确定凭据访问控制策略;通知点击回跳需先定义固定、无敏感信息的动作和目标页面。三项均不是当前发布前置条件。
## 12. 2026-07-17 Windows 正式发布链路记录
经发布负责人明确批准,Windows x64 NSIS 从内部兼容性验证目标提升为正式桌面发布目标。本轮完成:
- 正式 Desktop candidate workflow 拆分为 macOS 签名/公证、Windows 代码签名和跨平台聚合三个 job;两端都只接受与客户端版本一致的 `vX.Y.Z` tag。
- Windows job 从组织 secret 导入 Base64 PFX,动态配置证书指纹、SHA-256、RFC 3161 时间戳,构建 NSIS installer/updater,并校验应用与安装器的 Authenticode signer 和 timestamp certificate。
- 聚合 job 只在两个原生 job 均通过后生成统一 `latest.json`,同时包含 Universal macOS 的两个平台键和 `windows-x86_64`,并校验所有制品、`.sig` 与 checksum。
- 无签名 `.github/workflows/desktop-windows-internal.yml` 继续保持 branch-only 内部验证语义,不生成正式 updater feed。
- npm 依赖锁文件已升级至当前安全修复版本,生产和完整依赖审计均为 0 个已知漏洞;Axios 新 header 类型通过受控字符串归一化适配并新增单元测试。
创建正式 tag 前仍必须由发布负责人确认:
- GitHub Actions 已配置 updater secrets 和 versioned artifact base URL;默认平台签名路径还必须配置 Apple 与 Windows secrets 和 Windows timestamp URL。
- 若使用默认 Windows 签名路径,组织证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。
- macOS/Windows 原生 candidate job、联合 feed 校验、真实安装/升级和生产下载源上传全部通过。
## 13. 2026-07-17 v0.1.0 平台签名例外记录
经发布负责人明确批准,v0.1.0 可在无法提供 Apple Developer 和 Windows 组织代码签名凭据时受控分发。该批准仅覆盖精确版本 `0.1.0`
- `frontend/desktop-release-policy.json` 默认仍为 `signed`,仅将 v0.1.0 列入 `unsignedPlatformExceptions`;后续版本不会继承本例外。
- macOS 使用 Tauri `signingIdentity: "-"` 生成 ad-hoc 签名 Universal app/DMG,必须通过 `codesign --verify` 并确认 `Signature=adhoc`,不执行或声称 Apple 公证。
- Windows 应用和 NSIS 安装器不做 Authenticode 签名,必须通过 `Get-AuthenticodeSignature` 确认 `NotSigned`,不执行或声称 RFC 3161 时间戳。
- 两端仍必须从同一 `v0.1.0` tag 和 SHA 构建,使用同一 updater 私钥生成 `.sig`,并通过联合 feed、checksum 和 provenance 校验。
- GitHub Actions 额度不可用期间,发布负责人额外批准从最终 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS 制品;Windows 必须在恢复后从同一 tag/SHA 后补。
- macOS 先行 Release 必须包含 DMG、updater 包及 `.sig`、checksum、provenance、`UNSIGNED-PLATFORM` 和 Windows pending 说明,且不得包含或替换生产 `latest.json`
- Windows `NotSigned` 实物和联合 feed 全部验证通过后,才允许激活生产 updater feedtag 不得为补充 Windows 制品而移动。
- 制品与 Actions artifact 名称必须含 `_UNSIGNED``UNSIGNED-PLATFORM`,发布目录必须携带 `UNSIGNED-PLATFORM-RELEASE.txt``DESKTOP-RELEASE-PROVENANCE.json`
- 受控分发说明必须明确提示 macOS Gatekeeper 与 Windows SmartScreen 警告;平台未签名制品不得描述为 Apple/Microsoft 信任或已公证/已 Authenticode 签名。
本地实物验证已完成:使用当前 updater 私钥成功构建 Universal `x86_64 arm64` macOS app、DMG、`.app.tar.gz``.sig``codesign --verify --deep --strict` 通过,签名详情为 `Signature=adhoc``TeamIdentifier=not set`Gatekeeper 拒绝符合预期。Windows `NotSigned`、NSIS 和 updater `.sig` 仍须由 `windows-latest` 原生 job 在正式 tag 上验证。
+18
View File
@@ -305,9 +305,27 @@ Before promoting `main` to `release`, confirm:
- release checklist is complete
- production initialization, build, and smoke verification are complete
- Desktop updater signatures are present and verified
- platform signing is either complete or the exact version has an approved,
checked-in exception in `frontend/desktop-release-policy.json`; the current
v0.1.0 exception requires macOS ad-hoc signing, unsigned Windows artifacts,
controlled distribution, and explicit trust warnings
- rollback or remediation path is clear
- release tag has been prepared
Platform signing remains the default for versions without an exact approved
exception. An exception never permits disabling Tauri updater signatures or
building Web, macOS, and Windows from different tags or commits.
For v0.1.0 only, the release owner also approved a staged contingency while
hosted Actions capacity is unavailable: macOS may be built on a controlled
local macOS host from the immutable final `v0.1.0` tag and published first.
Windows remains pending and must later be built from that same tag and SHA.
The macOS-only release must carry checksums, provenance, updater signature,
platform-trust warnings, and an explicit Windows-pending notice. It must not
publish or replace production `latest.json`; updater feed activation waits for
combined macOS and Windows verification.
Reference:
- [docs/guides/release-checklist.md](guides/release-checklist.md)
+3 -3
View File
@@ -77,12 +77,12 @@ API
- `POST /api/v1/desktop-notifications/ack`
- `POST /api/v1/desktop-notifications/{distribution_id}/read`
claim 跨有效项目查询匹配当前用户或角色的活动文件分发,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。
claim 跨有效项目查询当前用户未读且未解决的通用业务提醒,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。系统通知只是通用提醒 Feed 的可选投递通道,不单独维护文件分发提醒来源。
系统通知正文只显示通用内容:
- 标题:`CTMS 文件更新`
- 正文:`有新的文件版本待查看`
- 标题:`CTMS 待办提醒`
- 正文:`有新的业务提醒待查看`
项目、文件、版本等详细信息仅在应用内列表显示,避免锁屏泄露。
+22 -4
View File
@@ -147,12 +147,12 @@ bash scripts/install.sh release --base-url https://ctms.example.com
```text
1. 检查 docker、docker compose、openssl、curl。
2. 检查 .env已存在则保留不改,不存在则新建。
3. 新建 main/release 的 .env 时自动生成 JWT 和 RSA 私钥。
2. 检查 .env保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建。
3. 所有环境自动生成独立的 ONLYOFFICE JWT 与稳定实例标识;新建 main/release 的 .env 时同时生成登录 JWT 和 RSA 私钥。
4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。
5. 启动容器;默认执行 docker compose up -d --build--skip-build 时执行 docker compose up -d
5. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICEONLYOFFICE 不再使用可选 Compose Profile
6. 执行 docker compose run --rm backend python -m alembic upgrade head。
7. 检查容器状态、后端环境、/health、/api/v1/auth/login-key。
7. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck
```
更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。
@@ -195,6 +195,12 @@ ENV=development
JWT_SECRET_KEY=dev-secret
LOGIN_RSA_KEY_ID=default
LOGIN_RSA_PRIVATE_KEY=
ONLYOFFICE_ENABLED=true
ONLYOFFICE_JWT_SECRET=<替换为独立的随机密钥,至少 32 个字符>
ONLYOFFICE_INTERNAL_URL=http://onlyoffice
ONLYOFFICE_STORAGE_BASE_URL=http://backend:8000
ONLYOFFICE_INSTANCE_ID=ctms-dev-<替换为随机实例标识>
ONLYOFFICE_CONFIG_TTL_SECONDS=300
EOF
```
@@ -316,6 +322,12 @@ ENV=production
JWT_SECRET_KEY=<替换为 JWT 密钥>
LOGIN_RSA_KEY_ID=main-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<替换为 RSA 私钥单行文本>
ONLYOFFICE_ENABLED=true
ONLYOFFICE_JWT_SECRET=<替换为不同于 JWT_SECRET_KEY 的独立随机密钥>
ONLYOFFICE_INTERNAL_URL=http://onlyoffice
ONLYOFFICE_STORAGE_BASE_URL=http://backend:8000
ONLYOFFICE_INSTANCE_ID=ctms-main-<替换为随机实例标识>
ONLYOFFICE_CONFIG_TTL_SECONDS=300
EOF
```
@@ -495,6 +507,12 @@ ENV=production
JWT_SECRET_KEY=<替换为生产 JWT 密钥>
LOGIN_RSA_KEY_ID=release-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<替换为生产 RSA 私钥单行文本>
ONLYOFFICE_ENABLED=true
ONLYOFFICE_JWT_SECRET=<替换为不同于 JWT_SECRET_KEY 的独立生产随机密钥>
ONLYOFFICE_INTERNAL_URL=http://onlyoffice
ONLYOFFICE_STORAGE_BASE_URL=http://backend:8000
ONLYOFFICE_INSTANCE_ID=ctms-release-<替换为随机实例标识>
ONLYOFFICE_CONFIG_TTL_SECONDS=300
EOF
```
+9 -4
View File
@@ -106,8 +106,9 @@ npm run desktop:build:app
npm run desktop:build:app
```
正式桌面发布构建仍必须设置 updater 签名私钥和 Apple 签名/公证变量后,先执行
`npm run desktop:release-readiness:check`,再执行 `npm run desktop:build:macos-release -- --ci`
正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥,updater 签名不可关闭。平台签名默认路径仍要求 macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行,Windows 设置 PFX、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行。只有 `frontend/desktop-release-policy.json` 对当前精确版本存在已批准例外时,workflow 才可改为 macOS ad-hoc、Windows Authenticode 未签名路径;例外制品必须标注 `UNSIGNED-PLATFORM`、生成 provenance 和风险说明。两个平台与联合 feed 校验通过后才能汇总正式 updater feed。
v0.1.0 额外批准 GitHub Actions 额度不可用时的分阶段应急路径:先在受控 macOS 主机从最终、不可移动的 `v0.1.0` tag/SHA 本地构建并上传 macOS ad-hoc 制品,随附 updater `.sig`、checksum、provenance、平台未签名警告和 Windows pending 说明;Windows 恢复后必须从同一 tag/SHA 后补。macOS 先行阶段不得发布或替换生产 `latest.json`,联合 feed 必须等待 Windows `NotSigned` 实物验证完成。
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
@@ -207,6 +208,7 @@ dev -> main
- 前后端测试通过
- 网页端构建通过
- macOS 桌面端构建通过
- Windows x64 NSIS 兼容性构建通过;正式发布时平台签名和时间戳验证通过,或当前精确版本的已批准平台签名例外验证通过
- 数据库迁移经过验证
- 已知风险和回滚方式已记录
@@ -281,7 +283,7 @@ main -> release
- 回归测试通过
- 数据库迁移和回滚方案确认
- 网页端生产构建通过
- 桌面端生产构建通过
- macOS 与 Windows 桌面端生产构建均从候选 tag 通过
- 发布说明完成
- 生产配置和密钥不在仓库中
- 正式版本号已经统一
@@ -295,7 +297,9 @@ git tag -a v1.2.0 -m "CTMS v1.2.0"
git push origin v1.2.0
```
网页端桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。
网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。两个平台的 updater 签名制品必须始终验证通过;macOS 签名/公证和 Windows 代码签名/RFC 3161 时间戳必须验证通过,除非 `frontend/desktop-release-policy.json` 对该精确版本记录了已批准例外。采用例外时必须验证 macOS 确为 ad-hoc、Windows 确为 `NotSigned`,并随安装包分发 `UNSIGNED-PLATFORM` 风险说明、provenance 和 checksum,之后才能最后原子替换生产 `latest.json`
若执行 v0.1.0 的已批准分阶段应急路径,首次 macOS Release 可先于 Windows 发布,但 tag/SHA 不得移动;首次 Release 不包含 `latest.json`,并必须明确标记 Windows pending。Windows 后补且联合 feed 验证通过后,才允许按上述顺序激活生产 updater feed。
发布记录至少包含:
@@ -450,6 +454,7 @@ git log --oneline --decorate --graph --all -30
- [ ] `npm run build` 通过
- [ ] `npm run desktop:build:app` 通过
- [ ] 正式桌面发布构建已使用 updater 签名私钥执行
- [ ] 平台签名已验证,或当前精确版本已在 `frontend/desktop-release-policy.json` 获批例外且 `UNSIGNED-PLATFORM` 风险说明、provenance 和 checksum 齐备
- [ ] 数据库迁移与回滚方案确认
- [ ] 发布说明完成
- [ ] `main -> release` 合并完成
+116 -28
View File
@@ -66,8 +66,8 @@ Manual edits that leave these files inconsistent fail CI.
## Stabilization Gates
Client builds require Node.js 22.13.0 or newer. The development frontend container,
Web CI, macOS release candidates, Windows internal validation, and the production
Web image use the same Node 22.13 baseline.
Web CI, macOS and Windows release candidates, Windows internal validation, and the
production Web image use the same Node 22.13 baseline.
Client release candidates must pass the shared Web checks and the Desktop
release/security gate before promotion:
@@ -85,8 +85,13 @@ npm run build
npm run desktop:build:app
```
`release:env:check` verifies build channel and commit metadata, and can be
made strict for signed Desktop builds with `REQUIRE_DESKTOP_SIGNING=true`.
`release:env:check` verifies build channel and commit metadata, and becomes
platform-strict with `REQUIRE_DESKTOP_SIGNING=true` on macOS or
`REQUIRE_WINDOWS_SIGNING=true` on Windows. Formal native jobs also set
`REQUIRE_UPDATER_SIGNING=true`, `DESKTOP_RELEASE_PLATFORM`, and the
version-derived `DESKTOP_PLATFORM_SIGNING_MODE`. The exact-version policy in
`frontend/desktop-release-policy.json` is checked with
`npm run desktop:release-policy:check`.
`desktop:release:check` statically verifies the Tauri bundle, updater public
key, CSP, capability scopes, command allowlist, query-token ban, generic system
notification boundary, CI gate coverage, and secure session token boundary. The
@@ -96,7 +101,7 @@ full manual release, security, regression, and Desktop UX checklist lives in
## Promotion
1. Merge feature branches into `dev`.
2. Require the shared client/Web and macOS Desktop CI jobs to pass.
2. Require the shared client/Web and macOS/Windows Desktop CI jobs to pass.
3. Promote the accepted scope from `dev` to `main`.
4. Set the release version and complete regression testing on `main`.
5. Promote `main` to `release`.
@@ -130,11 +135,11 @@ for `localhost`, `127.0.0.1`, or `::1`.
The release pipeline must:
1. build from the accepted release tag and commit;
2. build macOS Universal desktop artifacts;
3. sign and notarize the macOS app;
4. produce updater artifacts and `.sig` files with the updater private key;
5. generate `latest.json` and a checksum manifest with `npm run desktop:update-feed:create`;
6. verify the feed with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir>`;
2. build the Universal macOS app/DMG and Windows x64 NSIS installer from that tag;
3. use Apple signing/notarization and Windows Authenticode/RFC 3161 signing by default, or use a checked-in exact-version exception that constrains macOS to ad-hoc signing and Windows to Authenticode-unsigned output;
4. produce macOS `.app.tar.gz` and Windows `.nsis.zip` updater artifacts plus their `.sig` files with the shared updater private key;
5. generate `DESKTOP-RELEASE-PROVENANCE.json`, one combined `latest.json`, and a checksum manifest;
6. verify all macOS and Windows entries with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`;
7. upload immutable artifacts first;
8. atomically replace `latest.json` last.
@@ -142,28 +147,73 @@ For Universal macOS artifacts, `latest.json` must provide both
`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
update package.
The signed release candidate workflow lives at
The same feed must also contain `windows-x86_64`, pointing to the updater-signed
NSIS `.nsis.zip` package. The Windows `.exe` installer is distributed next to
the updater package but is not used as the updater URL. Tauri updater signing is
mandatory in both platform-signed and platform-signing-exception modes.
The formal release candidate workflow lives at
`.github/workflows/desktop-release-candidate.yml`. It must be run from a
matching `vX.Y.Z` tag and produces a verified release directory as a GitHub
artifact. That artifact is still only a release candidate; the release owner
must upload immutable files to the production download origin and replace
`latest.json` atomically after validation.
## Windows Build Readiness
Platform signing defaults to `signed`. An exception is allowed only when
`frontend/desktop-release-policy.json` names the exact product version and
records release-owner approval. The current v0.1.0 exception uses macOS ad-hoc
signing and unsigned Windows application/installer binaries. Its artifact names
and release directory contain `UNSIGNED-PLATFORM`; the directory must include
`UNSIGNED-PLATFORM-RELEASE.txt`, `DESKTOP-RELEASE-PROVENANCE.json`, and
`SHA256SUMS.txt`. This does not establish Apple or Microsoft publisher trust,
and Gatekeeper or SmartScreen warnings are expected. Later versions return to
the signed default unless separately approved.
Second-phase Windows work is limited to CI compatibility validation. A
`windows-latest` x64 NSIS build may be produced for verification, but it is not
a formal deliverable until Windows code signing and release support are
approved.
### v0.1.0 staged macOS contingency
Windows validation must cover:
The release owner approved one additional v0.1.0 contingency for unavailable
hosted Actions capacity. A controlled local macOS host may build the macOS
ad-hoc artifacts from the immutable final `v0.1.0` tag and publish them first.
That initial GitHub Release must include the DMG, updater package and `.sig`,
`SHA256SUMS.txt`, provenance, the unsigned-platform warning, and an explicit
Windows-pending notice. Windows must later be built from the same tag and SHA.
The staged macOS release is an installer distribution, not an activated
cross-platform updater release. Do not upload or replace production
`latest.json` until the Windows `NotSigned` installer/updater has been built and
the combined macOS/Windows feed passes full verification. This fallback is
limited to v0.1.0 and does not authorize local formal builds for later versions.
## Windows Release and Internal Validation
Windows x64 NSIS is an approved formal Desktop target. Formal Windows builds
run in `.github/workflows/desktop-release-candidate.yml` from the same exact
`vX.Y.Z` tag and SHA as Web and macOS. The default signed path must:
- import a Base64-encoded PFX from `WINDOWS_CERTIFICATE` using
`WINDOWS_CERTIFICATE_PASSWORD`;
- configure the imported certificate thumbprint, SHA-256 digest,
`WINDOWS_TIMESTAMP_URL`, and RFC 3161 timestamp mode dynamically in CI;
- set `REQUIRE_WINDOWS_SIGNING=true` and use the updater signing secrets;
- verify the application and installer with `Get-AuthenticodeSignature`,
including signer and timestamp certificates;
- publish the signed NSIS `.exe`, `.nsis.zip`, and `.nsis.zip.sig` into the
combined verified Desktop release directory.
For an approved exact-version unsigned-platform exception, the Windows job
must instead leave the application and installer Authenticode-unsigned, require
`Get-AuthenticodeSignature` to return `NotSigned`, append `_UNSIGNED` to the
installer and updater artifact names, and still create and verify the updater
`.sig`. A certificate secret and timestamp URL are not required in this mode.
Windows release validation also covers:
- WebView2 runtime prerequisite behavior;
- user-level installer assumptions;
- Windows Credential Manager session storage;
- path handling and temporary file cleanup;
- notification and updater compilation;
- future code-signing requirements.
- code-signing trust, timestamp validity, and updater signature verification.
The manual internal Windows validation workflow lives at
`.github/workflows/desktop-windows-internal.yml`. It is `workflow_dispatch`
@@ -172,8 +222,39 @@ only, runs on `windows-latest`, injects `VITE_BUILD_CHANNEL` and
and Desktop safety gates, builds an unsigned NSIS installer with updater
artifacts disabled, and uploads the `.exe` plus `SHA256SUMS.txt` as a GitHub
Actions artifact. This workflow is for internal compatibility verification
only; it must not generate `latest.json`, update feeds, signed release
directories, or formal Windows release artifacts.
only; it must not generate `latest.json`, update feeds, verified release
directories, or formal Windows release artifacts. A successful internal build
does not substitute for the tag-only formal workflow, including when that
formal workflow uses an approved platform-signing exception.
The formal workflow always requires these organization settings:
- secrets: `TAURI_SIGNING_PRIVATE_KEY` and
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`;
- client default CTMS origin: repository variable `VITE_DESKTOP_SERVER_URL`;
- shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the
manual workflow input.
`VITE_DESKTOP_SERVER_URL` must be an HTTPS origin without credentials, path,
query, or fragment. Vite embeds it as the Desktop client's first-run default;
the runtime source must not contain the production hostname. A user can still
override this default in Desktop server settings, where the persisted manual
value takes precedence and switching origins clears the existing session and
server-scoped client state. Do not reuse this value as
`DESKTOP_UPDATE_BASE_URL`: the latter points to immutable updater artifacts,
not the CTMS business API.
The default signed path additionally requires `WINDOWS_CERTIFICATE`,
`WINDOWS_CERTIFICATE_PASSWORD`, and `WINDOWS_TIMESTAMP_URL`, plus the Apple
credentials documented by the release owner. The v0.1.0 exception does not
require those platform certificate settings.
The checked-in workflow implements the exportable PFX path. Confirm that the
organization's certificate policy permits an exportable CI certificate before
provisioning it. If the selected CA provides only hardware- or cloud-backed
keys, replace the PFX import with a reviewed Tauri `signCommand` integration
(for example, the organization's Azure signing provider) while retaining the
same Authenticode and timestamp verification gates.
## Required Checks
@@ -183,6 +264,7 @@ npm ci
npm run version:check
export VITE_BUILD_CHANNEL=release
export VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
export VITE_DESKTOP_SERVER_URL="https://ctms.example.com"
npm run release:env:check
npm run runtime:check
npm run desktop:release:check
@@ -193,15 +275,21 @@ npm run build
npm run desktop:build:app
export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
export REQUIRE_DESKTOP_SIGNING=true
export REQUIRE_UPDATER_SIGNING=true
export DESKTOP_RELEASE_PLATFORM=macos
export DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception
export ALLOW_UNSIGNED_PLATFORM_RELEASE=true
npm run release:env:check
npm run desktop:release-readiness:check
npm run desktop:build:macos-release -- --ci
npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir>
npm run desktop:build:macos-unsigned-release -- --ci
npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --platform-artifact windows-x86_64=<CTMS.nsis.zip> --include <CTMS.dmg> --include <CTMS-installer.exe> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance
```
The Desktop build must run on macOS for the current first-phase target. A signed
or notarized public release additionally requires the Apple credentials defined
by the release owner. A formal second-phase desktop release also requires the
updater signing key; unsigned internal builds are not formal distributions.
The macOS and Windows builds run in their native CI jobs and always use the same
updater signing key. In the default path, macOS requires Apple
signing/notarization credentials and Windows requires the PFX/password and
timestamp URL. In the approved v0.1.0 exception, macOS must verify
`Signature=adhoc`, Windows must verify `NotSigned`, and both must carry explicit
platform-trust warnings. Native artifacts are aggregated only after both jobs
and the combined updater feed pass.
+23 -21
View File
@@ -1,26 +1,28 @@
# ONLYOFFICE 只读预览运行说明
# ONLYOFFICE 标准组件运行说明
当前集成只用于附件和文档版本的 Office 文件只读预览。PDF、图片、另存为和桌面端“打开”仍使用原有链路;第一期不启用编辑、保存回调、历史记录或强制保存
ONLYOFFICE Document Server 是 CTMS 的标准部署组件,用于附件和文档版本预览,以及共享库在线协作编辑。PDF、图片、另存为和桌面端“打开”仍使用各自原有链路。
## 开发环境启动
## 默认安装
普通 `docker compose up` 不会拉取或启动 Document Server。开发环境需要预览时执行专用启动脚本
执行标准安装即可同时安装 CTMS 与 ONLYOFFICE
```bash
bash scripts/install.sh dev
```
安装脚本会自动完成以下工作:
- 首次安装时生成独立的 256-bit 随机 `ONLYOFFICE_JWT_SECRET`,写入根目录 `.env`,且不在终端回显。
- 自动生成稳定、按部署环境隔离的实例标识并写入 `.env`
- 将 `.env` 权限设置为 `0600`;该文件已被 Git 忽略。
- 默认构建并启动 backend、Document Server 与 Nginx,不再使用可选 Compose Profile。
- 将 Document Server 纳入容器状态、后端配置和 HTTP 健康检查。
- 后续启动默认复用密钥,防止后端和 Document Server 因签名密钥漂移而无法通信。
开发环境需要单独重建、验证或主动轮换密钥时,可使用兼容维护脚本:
```bash
bash scripts/onlyoffice-dev-up.sh
```
脚本会自动完成以下工作:
- 首次启动时生成 256-bit 随机 `ONLYOFFICE_JWT_SECRET`,写入根目录 `.env`,且不在终端回显。
- 自动生成稳定的开发实例标识并写入 `.env`
- 将 `.env` 权限设置为 `0600`;该文件已被 Git 忽略。
- 使用同一环境重建 backend,并显式启用 `office` Profile 启动 Document Server。
- 后续启动默认复用密钥,防止后端和 Document Server 因签名密钥漂移而无法通信。
需要主动轮换开发密钥时执行:
```bash
bash scripts/onlyoffice-dev-up.sh --rotate-secret
```
@@ -30,7 +32,7 @@ bash scripts/onlyoffice-dev-up.sh --rotate-secret
## 配置边界
- `ONLYOFFICE_ENABLED` 默认 `false`
- Compose 安装中的 `ONLYOFFICE_ENABLED` 默认 `true`;安装或更新旧环境时也会自动启用
- `ONLYOFFICE_INTERNAL_URL` 默认 `http://onlyoffice`,只供后端健康探测。
- `ONLYOFFICE_STORAGE_BASE_URL` 默认 `http://backend:8000`,只允许后端生成固定的内部文件地址。
- `ONLYOFFICE_CONFIG_TTL_SECONDS` 默认 300 秒,允许范围为 60–900 秒。
@@ -38,8 +40,8 @@ bash scripts/onlyoffice-dev-up.sh --rotate-secret
- 配置响应禁止缓存;JWT、内部文件 URL 和磁盘路径不得进入日志、审计详情或桌面缓存。
- `onlyoffice_data``onlyoffice_lib``onlyoffice_logs` 不是 CTMS 业务权威数据,不纳入业务备份或恢复来源。
## 生产启用前
## 生产部署要求
生产保持功能开关关闭,直至商业许可、批准镜像和真实授权环境回归完成。启用时必须使用生产随机密钥和稳定、唯一的 `ONLYOFFICE_INSTANCE_ID`,并完成 Web、macOS 和 Windows 内测端的真实 DOCX/XLSX/PPTX/WPS/ET/DPS 预览验证。
标准生产安装同样包含 ONLYOFFICE,并自动生成独立随机密钥和稳定、唯一的 `ONLYOFFICE_INSTANCE_ID`。正式上线前仍必须确认商业许可或 Community 版本授权边界、批准镜像、容量与备份监控策略,并完成 Web、macOS 和 Windows 内测端的真实 DOCX/XLSX/PPTX/WPS/ET/DPS 预览验证。
故障回退关闭 `ONLYOFFICE_ENABLED`;不得影响附件下载、另存为、打开、PDF 或图片预览。Community 与商业版本的授权边界以 [ONLYOFFICE 官方许可说明](https://helpcenter.onlyoffice.com/docs/faq/docs-community.aspx) 为准。
故障回退可临时关闭 `ONLYOFFICE_ENABLED`下一次标准安装或更新会按标准组件策略重新启用。回退不得影响附件下载、另存为、打开、PDF 或图片预览。Community 与商业版本的授权边界以 [ONLYOFFICE 官方许可说明](https://helpcenter.onlyoffice.com/docs/faq/docs-community.aspx) 为准。
+5 -5
View File
@@ -20,7 +20,7 @@
- 文件所有者始终拥有编辑、管理、导出和转让能力;文件管理者始终拥有编辑、管理和导出能力,但不能转让所有权;文件编辑者可以编辑,导出能力由文件“权限设置”开关控制。系统管理员继续保留平台级完整访问能力。
- 授予编辑者、管理者或转让所有权时,目标账号必须是当前项目的有效成员,并具备对应项目角色资格;不合格账号在联系人列表中不可选择,后端同时拒绝绕过界面的授权请求。
- 文件管理者可开启“允许申请编辑权限”。只读项目成员可从 CTMS 顶栏或 ONLYOFFICE 的“请求编辑”入口提交申请;批准后写入文件级编辑者授权,拒绝或重复处理均有明确状态和审计记录。匿名链接访问者不能申请系统账号权限。
- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”。原逾期 AE 和逾期监查问题也先同步为通用通知,再由同一 Feed 返回,不再由网页和桌面布局分别临时汇总
- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”,审批结果作为一次性信息通知申请人。AE、监查问题、文件回执和里程碑时效提醒也由同一 Feed 返回,具体规则见 `docs/project-notifications.md`
- Excel 文件可通过“允许所有协作者添加、删除工作表”控制工作簿结构。关闭时 CTMS 在新的不可变修订中启用工作簿结构保护并推进文件代次;重新开启时恢复文件原有的工作簿保护状态。该开关作用于所有协作者且不影响单元格内容编辑权限。
- 仅文档所有者和系统管理员可转让所有权。目标联系人必须具备文件管理者资格;转让后目标联系人升级为文件管理者,原所有者保留管理者身份,所有权变更写入审计。
- 文件编辑器“下载为”由文件导出规则判定:所有者和管理者始终允许,编辑者受文件开关控制,并由 CTMS 网页/桌面文件运行时保存到本机且记录下载审计。“另存为…”还会在项目工作区创建独立协作文件,因此额外要求项目级 `collaboration:create` 权限。
@@ -49,13 +49,13 @@
## 本地开发
执行
标准开发安装会直接启动 ONLYOFFICE
```bash
bash scripts/onlyoffice-dev-up.sh
bash scripts/install.sh dev
```
脚本会生成或复用独立的开发 JWT 密钥,启用 `office` Profile,并验证只读预览及在线协作的后端路由。协作修订保存在现有后端上传卷下的独立 `collaboration/` 目录。
安装脚本会生成或复用独立的开发 JWT 密钥和稳定实例标识,默认启动 Document Server,并验证后端配置和 HTTP 健康状态。`bash scripts/onlyoffice-dev-up.sh` 仅用于开发环境单独重建、验证或轮换密钥。协作修订保存在现有后端上传卷下的独立 `collaboration/` 目录。
关键配置:
@@ -67,7 +67,7 @@ bash scripts/onlyoffice-dev-up.sh
- `ONLYOFFICE_CONFIG_TTL_SECONDS`
- `COLLABORATION_MAX_FILE_BYTES`
生产环境仍默认关闭 ONLYOFFICE启用前必须按既有发布要求确认镜像许可、JWT 密钥、内部网络、备份与监控。
生产标准安装同样包含 ONLYOFFICE正式上线前必须按既有发布要求确认镜像许可、独立 JWT 密钥、内部网络、容量、备份与监控。
参考:
+72
View File
@@ -0,0 +1,72 @@
# 项目提醒中心
## 产品边界
项目提醒是既有业务状态面向明确收件人的可操作投影,不是独立任务、日历或聊天系统。源业务记录和业务审计始终是权威数据;提醒的阅读、桌面投递均不能替代 AE 上报、问题关闭、文件回执、审批或其他业务动作。
当前范围只包括已认证在线客户端:
- 网页端和桌面端共用当前项目的通用提醒 Feed。
- 顶栏铃铛展示摘要,`/project/notifications` 提供当前提醒、未读和待处理筛选。已经解决或因权限变化失效的提醒不再向客户端返回。
- 桌面系统通知是用户主动开启的可选投递渠道,只显示不含项目、文件、受试者或风险详情的通用文案。
- 不提供离线提醒、本地业务权威数据、个人自由定时任务、短信、即时通讯或浏览器 Push。
- 管理员权限监控告警、登录会话提醒和客户端更新“稍后提醒”不进入业务提醒中心。
## 状态模型
`notifications` 是收件人级通用提醒表:
- `read_at`:用户已经看过提醒。
- `resolved_at`:源业务已经完成、关闭、失效,或一次性信息通知已经被阅读。
- `requires_action`:区分业务待办和一次性信息通知。
- `due_at`:用于展示业务截止时间;业务规则仍以源记录为准。
- `source_type``source_id``source_version``dedupe_key`:用于来源追踪、幂等同步、阶段升级和自动关闭。
阅读待处理提醒不会写入 `resolved_at`。业务动作完成后由对应服务即时关闭提醒,定时同步还会对遗漏或权限变化进行对账。一次性信息通知在首次阅读时归档。
桌面投递状态继续保存在 `desktop_notification_deliveries`,但通过 `notification_id` 关联通用提醒。投递成功只记录 `delivered_at`,不自动标记业务提醒已读或已处理;提醒升级或重新打开时可以重新进入桌面投递队列。
桌面客户端每 60 秒执行一次在线投递检查:先确认本机系统权限,再读取当前账号的服务端订阅,随后领取待投递提醒、调用系统通知并确认实际成功投递的提醒。任一系统通知调用失败时不会确认该条提醒;网络或投递失败采用指数退避,最长 15 分钟。用户可在“设置 → 通知”查看本次客户端运行期间的最近检查、最近投递和当前链路状态,并主动执行一次真实业务提醒检查。
## 当前规则
| 来源 | 收件人 | 触发和升级 | 自动关闭 |
|---|---|---|---|
| 协作编辑申请 | 文件所有者、文件管理者 | 申请创建 | 申请批准或拒绝 |
| 协作申请结果 | 申请人 | 申请批准或拒绝 | 申请人阅读后归档 |
| 文件分发与回执 | 指定用户、指定角色、中心联系人 | 新分发;3 天内到期;逾期 | 用户完成接收回执、分发关闭或权限失效 |
| AE 上报时效 | 具备 AE 读取权限且在中心范围内的项目成员 | 3 天内到期;逾期;按数量增加重新提醒 | 数量归零或权限失效 |
| 监查问题整改 | 具备监查问题读取权限且在中心范围内的项目成员 | 3 天内到期;逾期;按数量增加重新提醒 | 数量归零或权限失效 |
| 项目里程碑 | 里程碑负责人 | 7 天内到期;逾期;日期或状态变化重新提醒 | 完成、负责人变化或日期移出提醒窗口 |
| 受试者访视窗口 | 具备访视更新权限的有效项目成员;CRA 仅限负责中心 | 窗口开始前 3 天、窗口进行中;错过窗口后升级 | 录入实际访视、取消访视、受试者结束、中心停用或权限/中心范围失效 |
所有规则都要求当前有效项目成员和对应业务权限。系统管理员不会仅因平台权限而批量接收所有项目提醒;需要作为有效项目成员或明确业务收件人进入提醒范围。
日期型 AE、里程碑和访视窗口规则按 `Asia/Shanghai` 业务日计算,带时区的截止时间统一按 UTC 存储和比较。若未来引入项目级时区,应由项目配置替代固定业务时区,不能使用浏览器本地时区驱动服务端规则。
访视提醒按访视记录生成并可跳转到对应受试者详情,但提醒标题和正文不包含受试者编号、姓名、中心名称或其他可识别信息。收件人资格以 `visits:update` 及其前置权限为准,避免仅具备只读权限的 PV、QA、CTA 等角色被动接收受试者访视提醒。
## 同步与诊断
服务端启动提醒同步任务,默认每 300 秒对所有有效项目成员执行幂等同步;间隔通过 `NOTIFICATION_SYNC_INTERVAL_SECONDS` 配置,允许 603600 秒。PostgreSQL advisory lock 防止多个后端进程重复执行全量任务。
用户读取 Feed 时还会执行一次当前用户轻量对账,用于弥补短时任务失败。单个成员同步失败不会中断其他成员;失败写入 `ctms.notifications` 日志。提醒创建、更新和自动关闭本身不替代源业务审计。
## 桌面隐私边界
系统通知固定为:
- 标题:`CTMS 待办提醒`
- 正文:`有新的业务提醒待查看`
系统通知 API 不接受动态标题或正文。项目名、文件名、版本、受试者、AE、监查问题等详情只允许在完成 token 校验和项目权限确认后的应用内 Feed 中展示;访视提醒即使在应用内 Feed 也不直接显示受试者标识,只通过受控详情路径访问。
## 系统通知授权边界
系统权限和服务端订阅是两个独立条件:
- 首次开启时,客户端先请求操作系统授权;只有操作系统返回已授权,才开启当前账号的服务端订阅。
- 用户在操作系统中撤销权限后,服务端订阅可以仍然保持开启,但客户端不会领取提醒,设置页会明确显示“系统权限已拒绝”。
- macOS 或 Windows 已拒绝权限时,系统通常不会再次展示授权框。设置页分别引导用户前往“系统设置 → 通知 → CTMS”或“设置 → 系统 → 通知 → CTMS”,返回后通过“重新检测权限”恢复链路。
- “发送测试通知”只验证本机通知能力,不访问业务提醒队列;“立即检查业务提醒”会走服务端订阅、领取、系统投递和确认的真实路径。
- CTMS 不申请打开任意 URL、执行系统命令或读取操作系统通知内容的额外 Tauri 权限。授权引导仅展示设置路径,避免为便利入口扩大桌面原生能力边界。
+2
View File
@@ -2,6 +2,8 @@
VITE_RUNTIME_ENV=production
VITE_ALLOW_INSECURE_DEV_LOGIN=false
VITE_DEV_API_PROXY_TARGET=http://backend:8000
# Default CTMS origin embedded in Desktop builds; users can override it in Desktop settings.
VITE_DESKTOP_SERVER_URL=https://ctms.example.com
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
VITE_HMR_CLIENT_PORT=
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
+19
View File
@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"defaultPlatformSigningMode": "signed",
"updaterSigningRequired": true,
"unsignedPlatformExceptions": [
{
"version": "0.1.0",
"status": "approved",
"macos": "ad-hoc",
"windows": "unsigned",
"distribution": "controlled",
"macosLocalExactTagFallback": true,
"windowsDelivery": "deferred-same-tag",
"updaterFeedActivation": "after-combined-platform-verification",
"approvedOn": "2026-07-17",
"reason": "The v0.1.0 release owner cannot currently provide Apple Developer signing/notarization credentials or an organization Windows code-signing certificate, and hosted Windows build capacity is temporarily unavailable."
}
]
}
@@ -0,0 +1,12 @@
CTMS DESKTOP PLATFORM SIGNING WARNING
This release is distributed under the version-scoped v0.1.0 platform-signing exception.
- The macOS application uses an ad-hoc signature and is not Apple-notarized.
- The Windows application and installer have no Authenticode signature or RFC 3161 timestamp.
- macOS Gatekeeper and Windows SmartScreen warnings are expected.
- Tauri updater artifacts remain cryptographically signed and must pass updater feed verification.
- Platform availability may be staged; production latest.json remains withheld until macOS and Windows artifacts from the same tag pass combined verification.
- Install only after verifying the release tag, source commit, SHA256SUMS.txt, and DESKTOP-RELEASE-PROVENANCE.json through a trusted channel.
This exception does not apply to later versions unless they are separately approved in desktop-release-policy.json.
@@ -0,0 +1,9 @@
CTMS v0.1.0 WINDOWS RELEASE PENDING
This GitHub Release is the approved first stage of the v0.1.0 Desktop distribution.
- macOS Universal artifacts are available and use an ad-hoc platform signature.
- Windows x64 NSIS artifacts are not yet available.
- Windows must later be built from the same immutable v0.1.0 tag and Git commit.
- Production latest.json is intentionally withheld until Windows NotSigned artifacts and the combined updater feed pass verification.
- Do not interpret this staged release as a complete cross-platform automatic-update release.
+316 -187
View File
@@ -1001,9 +1001,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz",
"integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
"integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
"cpu": [
"arm"
],
@@ -1015,9 +1015,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz",
"integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
"integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
"cpu": [
"arm64"
],
@@ -1028,10 +1028,24 @@
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
"integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz",
"integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
"integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
"cpu": [
"x64"
],
@@ -1043,9 +1057,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz",
"integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
"integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
"cpu": [
"arm64"
],
@@ -1057,9 +1071,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz",
"integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
"integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
"cpu": [
"x64"
],
@@ -1071,13 +1085,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz",
"integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
"integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1085,13 +1102,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz",
"integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
"integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1099,13 +1119,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz",
"integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
"integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1113,11 +1136,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.5",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
"integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1125,13 +1153,33 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz",
"integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
"integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
"integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1139,13 +1187,33 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz",
"integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
"integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
"integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1153,13 +1221,16 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz",
"integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
"integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1167,13 +1238,16 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz",
"integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
"integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1181,13 +1255,16 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz",
"integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
"integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1195,13 +1272,16 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz",
"integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
"integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1209,9 +1289,26 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz",
"integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
"integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
"integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
"cpu": [
"x64"
],
@@ -1219,13 +1316,13 @@
"license": "MIT",
"optional": true,
"os": [
"linux"
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz",
"integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
"integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
"cpu": [
"arm64"
],
@@ -1237,9 +1334,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz",
"integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
"integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
"cpu": [
"arm64"
],
@@ -1251,9 +1348,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz",
"integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
"integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
"cpu": [
"ia32"
],
@@ -1265,9 +1362,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz",
"integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
"integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
"cpu": [
"x64"
],
@@ -1279,9 +1376,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz",
"integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
"integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
"cpu": [
"x64"
],
@@ -1589,7 +1686,9 @@
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
@@ -1634,15 +1733,15 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@@ -1651,13 +1750,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@@ -1688,9 +1787,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1701,13 +1800,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"@vitest/utils": "3.2.7",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
@@ -1716,13 +1815,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@@ -1731,9 +1830,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1744,13 +1843,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
@@ -2002,12 +2101,40 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/balanced-match": {
@@ -2181,7 +2308,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2239,21 +2365,15 @@
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
"zrender": "6.1.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/editorconfig": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
@@ -2432,7 +2552,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -2467,14 +2589,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -2591,7 +2715,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -2781,11 +2907,15 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash-unified": {
@@ -2876,7 +3006,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/muggle-string": {
@@ -2887,7 +3016,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@@ -3030,9 +3161,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3063,7 +3194,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"funding": [
{
"type": "opencollective",
@@ -3080,7 +3213,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -3096,8 +3229,13 @@
"license": "ISC"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -3110,11 +3248,13 @@
}
},
"node_modules/rollup": {
"version": "4.53.5",
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
"@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
@@ -3124,45 +3264,34 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.5",
"@rollup/rollup-android-arm64": "4.53.5",
"@rollup/rollup-darwin-arm64": "4.53.5",
"@rollup/rollup-darwin-x64": "4.53.5",
"@rollup/rollup-freebsd-arm64": "4.53.5",
"@rollup/rollup-freebsd-x64": "4.53.5",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.5",
"@rollup/rollup-linux-arm-musleabihf": "4.53.5",
"@rollup/rollup-linux-arm64-gnu": "4.53.5",
"@rollup/rollup-linux-arm64-musl": "4.53.5",
"@rollup/rollup-linux-loong64-gnu": "4.53.5",
"@rollup/rollup-linux-ppc64-gnu": "4.53.5",
"@rollup/rollup-linux-riscv64-gnu": "4.53.5",
"@rollup/rollup-linux-riscv64-musl": "4.53.5",
"@rollup/rollup-linux-s390x-gnu": "4.53.5",
"@rollup/rollup-linux-x64-gnu": "4.53.5",
"@rollup/rollup-linux-x64-musl": "4.53.5",
"@rollup/rollup-openharmony-arm64": "4.53.5",
"@rollup/rollup-win32-arm64-msvc": "4.53.5",
"@rollup/rollup-win32-ia32-msvc": "4.53.5",
"@rollup/rollup-win32-x64-gnu": "4.53.5",
"@rollup/rollup-win32-x64-msvc": "4.53.5",
"@rollup/rollup-android-arm-eabi": "4.62.2",
"@rollup/rollup-android-arm64": "4.62.2",
"@rollup/rollup-darwin-arm64": "4.62.2",
"@rollup/rollup-darwin-x64": "4.62.2",
"@rollup/rollup-freebsd-arm64": "4.62.2",
"@rollup/rollup-freebsd-x64": "4.62.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
"@rollup/rollup-linux-arm-musleabihf": "4.62.2",
"@rollup/rollup-linux-arm64-gnu": "4.62.2",
"@rollup/rollup-linux-arm64-musl": "4.62.2",
"@rollup/rollup-linux-loong64-gnu": "4.62.2",
"@rollup/rollup-linux-loong64-musl": "4.62.2",
"@rollup/rollup-linux-ppc64-gnu": "4.62.2",
"@rollup/rollup-linux-ppc64-musl": "4.62.2",
"@rollup/rollup-linux-riscv64-gnu": "4.62.2",
"@rollup/rollup-linux-riscv64-musl": "4.62.2",
"@rollup/rollup-linux-s390x-gnu": "4.62.2",
"@rollup/rollup-linux-x64-gnu": "4.62.2",
"@rollup/rollup-linux-x64-musl": "4.62.2",
"@rollup/rollup-openbsd-x64": "4.62.2",
"@rollup/rollup-openharmony-arm64": "4.62.2",
"@rollup/rollup-win32-arm64-msvc": "4.62.2",
"@rollup/rollup-win32-ia32-msvc": "4.62.2",
"@rollup/rollup-win32-x64-gnu": "4.62.2",
"@rollup/rollup-win32-x64-msvc": "4.62.2",
"fsevents": "~2.3.2"
}
},
"node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz",
"integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
@@ -3498,6 +3627,12 @@
"node": ">=18"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"devOptional": true,
@@ -3516,13 +3651,13 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -3614,20 +3749,20 @@
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
@@ -3657,8 +3792,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.7",
"happy-dom": "*",
"jsdom": "*"
},
@@ -3976,9 +4111,9 @@
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4015,19 +4150,13 @@
"license": "MIT"
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}
+4
View File
@@ -15,6 +15,10 @@
"desktop:build": "tauri build",
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
"desktop:build:macos-release": "tauri build --target universal-apple-darwin --bundles app,dmg",
"desktop:build:macos-unsigned-release": "tauri build --target universal-apple-darwin --bundles app,dmg --config '{\"bundle\":{\"macOS\":{\"signingIdentity\":\"-\"}}}'",
"desktop:build:windows-release": "tauri build --bundles nsis",
"desktop:release-policy:check": "node scripts/resolve-desktop-release-policy.mjs",
"desktop:release-provenance:create": "node scripts/create-desktop-release-provenance.mjs",
"desktop:update-feed:create": "node scripts/create-desktop-update-feed.mjs",
"desktop:bundle:dmg": "tauri build --bundles dmg",
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
@@ -0,0 +1,79 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const args = process.argv.slice(2);
const value = (name) => {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
};
const policy = await loadDesktopReleasePolicy();
const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy);
const tag = value("--tag") || process.env.GITHUB_REF_NAME;
const commit = value("--commit") || process.env.GITHUB_SHA;
const macosSigning = value("--macos-signing");
const windowsSigning = value("--windows-signing");
const releaseStage = value("--stage") || "complete";
const outputPath = resolve(
frontendDir,
value("--output") || "src-tauri/target/desktop-release-feed/DESKTOP-RELEASE-PROVENANCE.json",
);
const failures = [];
const assert = (condition, message) => {
if (!condition) failures.push(message);
};
assert(tag === `v${packageInfo.version}`, `Release provenance tag must be v${packageInfo.version}.`);
assert(/^[0-9a-f]{40}$/i.test(commit || ""), "Release provenance commit must be a full 40-character SHA.");
assert(macosSigning === resolution.macosSigning, `macOS signing mode must be ${resolution.macosSigning}.`);
assert(windowsSigning === resolution.windowsSigning, `Windows signing mode must be ${resolution.windowsSigning}.`);
assert(["complete", "macos-first"].includes(releaseStage), "Release provenance stage must be complete or macos-first.");
if (releaseStage === "macos-first") {
assert(
resolution.macosLocalExactTagFallback === true && resolution.windowsDelivery === "deferred-same-tag",
`v${packageInfo.version} does not approve a staged local macOS-first release.`,
);
}
if (failures.length > 0) {
throw new Error(`Desktop release provenance creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
}
const provenance = {
schemaVersion: 1,
product: "CTMS Desktop",
version: packageInfo.version,
tag,
commit,
platformSigningMode: resolution.platformSigningMode,
platformSigning: {
macos: macosSigning,
windows: windowsSigning,
},
updaterSigning: "required-and-verified",
artifactLabel: resolution.artifactLabel,
distribution: resolution.distribution,
stage: releaseStage,
platformAvailability:
releaseStage === "macos-first"
? { macos: "available", windows: "pending-same-tag" }
: { macos: "available", windows: "available" },
updaterFeedActivation:
releaseStage === "macos-first" ? "withheld-pending-combined-verification" : "combined-platform-verification-required",
warnings: resolution.warningRequired
? [
"macOS is ad-hoc signed and not Apple-notarized; Gatekeeper warnings are expected.",
"Windows is not Authenticode-signed or RFC 3161 timestamped; SmartScreen warnings are expected.",
"Verify the release tag, commit, updater signatures, and SHA256SUMS.txt through a trusted channel before installation.",
]
: [],
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(provenance, null, 2)}\n`);
console.log(`Desktop release provenance created at ${outputPath}`);
+81 -44
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
@@ -25,7 +25,7 @@ const assert = (condition, message) => {
if (!condition) fail(message);
};
const artifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT;
const macosArtifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT;
const outputDir = resolve(
frontendDir,
value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed",
@@ -34,18 +34,40 @@ const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString();
const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES;
const includes = values("--include").map((path) => resolve(frontendDir, path));
const platformArtifactSpecs = values("--platform-artifact");
const platformPattern = /^(?:darwin|windows|linux)-(?:x86_64|aarch64|i686|armv7)$/;
assert(Boolean(artifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required.");
assert(Boolean(macosArtifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required for the Universal macOS updater artifact.");
assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required.");
const resolvedArtifactPath = artifactPath ? resolve(frontendDir, artifactPath) : undefined;
const artifactName = resolvedArtifactPath ? basename(resolvedArtifactPath) : undefined;
const signaturePath = resolvedArtifactPath ? `${resolvedArtifactPath}.sig` : undefined;
const uploadFiles = [];
const platformArtifacts = new Map();
if (macosArtifactPath) {
const resolvedMacosArtifact = resolve(frontendDir, macosArtifactPath);
platformArtifacts.set("darwin-aarch64", resolvedMacosArtifact);
platformArtifacts.set("darwin-x86_64", resolvedMacosArtifact);
}
for (const spec of platformArtifactSpecs) {
const separator = spec.indexOf("=");
if (separator <= 0 || separator === spec.length - 1) {
fail(`--platform-artifact must use <os>-<arch>=<path>: ${spec}`);
continue;
}
const platform = spec.slice(0, separator);
const path = spec.slice(separator + 1);
assert(platformPattern.test(platform), `Unsupported updater platform key: ${platform}`);
assert(!platformArtifacts.has(platform), `Updater platform is configured more than once: ${platform}`);
if (platformPattern.test(platform) && !platformArtifacts.has(platform)) {
platformArtifacts.set(platform, resolve(frontendDir, path));
}
}
const readRequiredFile = async (path, description) => {
try {
return await readFile(path);
const data = await readFile(path);
assert(data.length > 0, `${description} must not be empty: ${path}`);
return data;
} catch (error) {
fail(`${description} cannot be read: ${path} (${error.message})`);
return undefined;
@@ -72,20 +94,16 @@ const normalizedBaseUrl = () => {
}
};
const copyIntoOutput = async (sourcePath) => {
const destination = resolve(outputDir, basename(sourcePath));
if (sourcePath !== destination) {
await copyFile(sourcePath, destination);
}
return destination;
};
if (resolvedArtifactPath && signaturePath) {
await readRequiredFile(resolvedArtifactPath, "Updater artifact");
const artifactSignatures = new Map();
const uniqueArtifactPaths = [...new Set(platformArtifacts.values())];
for (const artifactPath of uniqueArtifactPaths) {
const signaturePath = `${artifactPath}.sig`;
await readRequiredFile(artifactPath, "Updater artifact");
const signature = await readRequiredFile(signaturePath, "Updater artifact signature");
if (signature) {
const signatureText = signature.toString("utf8").trim();
assert(signatureText.length > 80, "Updater artifact signature is unexpectedly short.");
assert(signatureText.length > 80, `Updater artifact signature is unexpectedly short: ${signaturePath}`);
artifactSignatures.set(artifactPath, signatureText);
}
}
@@ -98,56 +116,75 @@ for (const includePath of includes) {
}
}
for (const [platform, artifactPath] of platformArtifacts) {
if (platform.startsWith("darwin-")) {
assert(artifactPath.endsWith(".app.tar.gz"), `${platform} must use a macOS .app.tar.gz updater artifact.`);
}
if (platform === "windows-x86_64") {
assert(artifactPath.endsWith(".nsis.zip"), "windows-x86_64 must use an NSIS .nsis.zip updater artifact.");
}
}
const uploadSources = [
...uniqueArtifactPaths.flatMap((path) => [path, `${path}.sig`]),
...includes,
];
const sourceByName = new Map();
for (const sourcePath of uploadSources) {
const name = basename(sourcePath);
const existing = sourceByName.get(name);
assert(!existing || existing === sourcePath, `Release files must have unique basenames: ${name}`);
sourceByName.set(name, sourcePath);
}
const baseUrl = normalizedBaseUrl();
if (failures.length === 0 && resolvedArtifactPath && signaturePath && artifactName && baseUrl) {
if (failures.length === 0 && baseUrl) {
await mkdir(outputDir, { recursive: true });
const artifactUrl = new URL(artifactName, baseUrl).toString();
assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json.");
assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters.");
const platforms = {};
for (const [platform, artifactPath] of platformArtifacts) {
const artifactUrl = new URL(basename(artifactPath), baseUrl).toString();
assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json.");
assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters.");
platforms[platform] = {
signature: artifactSignatures.get(artifactPath),
url: artifactUrl,
};
}
const signature = (await readFile(signaturePath, "utf8")).trim();
const latest = {
version: packageInfo.version,
pub_date: pubDate,
platforms: {
"darwin-aarch64": {
signature,
url: artifactUrl,
},
"darwin-x86_64": {
signature,
url: artifactUrl,
},
},
platforms,
};
if (notes) {
latest.notes = notes;
}
uploadFiles.push(await copyIntoOutput(resolvedArtifactPath));
uploadFiles.push(await copyIntoOutput(signaturePath));
for (const includePath of includes) {
uploadFiles.push(await copyIntoOutput(includePath));
const copiedFiles = [];
for (const sourcePath of sourceByName.values()) {
const destination = resolve(outputDir, basename(sourcePath));
if (sourcePath !== destination) {
await copyFile(sourcePath, destination);
}
copiedFiles.push(destination);
}
const latestPath = resolve(outputDir, "latest.json");
await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`);
uploadFiles.push(latestPath);
copiedFiles.push(latestPath);
const uniqueFiles = [...new Map(uploadFiles.map((path) => [basename(path), path])).values()];
const checksumLines = [];
for (const filePath of uniqueFiles) {
for (const filePath of copiedFiles) {
checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`);
}
const checksumPath = resolve(outputDir, "SHA256SUMS.txt");
await writeFile(checksumPath, `${checksumLines.join("\n")}\n`);
console.log(`Desktop update feed created in ${outputDir}`);
console.log(` - ${uniqueFiles.map((path) => basename(path)).join("\n - ")}`);
console.log(` - SHA256SUMS.txt`);
console.log(` - ${copiedFiles.map((path) => basename(path)).join("\n - ")}`);
console.log(" - SHA256SUMS.txt");
}
if (failures.length > 0) {
@@ -0,0 +1,97 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
export const desktopReleasePolicyPath = resolve(frontendDir, "desktop-release-policy.json");
const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
const validatePolicy = (policy) => {
const failures = [];
const assert = (condition, message) => {
if (!condition) failures.push(message);
};
assert(policy?.schemaVersion === 1, "desktop release policy schemaVersion must be 1.");
assert(
policy?.defaultPlatformSigningMode === "signed",
"desktop release policy must default platform signing to signed.",
);
assert(policy?.updaterSigningRequired === true, "desktop release policy must always require updater signing.");
assert(
Array.isArray(policy?.unsignedPlatformExceptions),
"desktop release policy unsignedPlatformExceptions must be an array.",
);
const versions = new Set();
for (const exception of policy?.unsignedPlatformExceptions || []) {
const prefix = `unsigned platform exception ${exception?.version || "<missing>"}`;
assert(semverPattern.test(exception?.version || ""), `${prefix} must use an exact X.Y.Z version.`);
assert(!versions.has(exception?.version), `${prefix} is duplicated.`);
versions.add(exception?.version);
assert(exception?.status === "approved", `${prefix} must have status approved.`);
assert(exception?.macos === "ad-hoc", `${prefix} must constrain macOS to ad-hoc signing.`);
assert(exception?.windows === "unsigned", `${prefix} must constrain Windows to unsigned.`);
assert(exception?.distribution === "controlled", `${prefix} must constrain distribution to controlled.`);
if (exception?.version === "0.1.0") {
assert(exception?.macosLocalExactTagFallback === true, `${prefix} must explicitly approve the local exact-tag macOS fallback.`);
assert(exception?.windowsDelivery === "deferred-same-tag", `${prefix} must defer Windows only from the same tag.`);
assert(
exception?.updaterFeedActivation === "after-combined-platform-verification",
`${prefix} must withhold the production updater feed until both platforms are verified.`,
);
}
assert(/^\d{4}-\d{2}-\d{2}$/.test(exception?.approvedOn || ""), `${prefix} must record an approval date.`);
assert(
typeof exception?.reason === "string" && exception.reason.trim().length >= 30,
`${prefix} must record a substantive reason.`,
);
}
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
};
export const loadDesktopReleasePolicy = async () => {
const policy = JSON.parse(await readFile(desktopReleasePolicyPath, "utf8"));
validatePolicy(policy);
return policy;
};
export const resolveDesktopReleasePolicy = (version, policy) => {
if (!semverPattern.test(version || "")) {
throw new Error(`Desktop release version must use exact X.Y.Z semver; found ${version || "<missing>"}.`);
}
const exception = policy.unsignedPlatformExceptions.find(
(candidate) => candidate.version === version && candidate.status === "approved",
);
if (!exception) {
return {
version,
platformSigningMode: "signed",
macosSigning: "apple-developer",
windowsSigning: "authenticode",
artifactLabel: "SIGNED-PLATFORM",
distribution: "formal",
warningRequired: false,
};
}
return {
version,
platformSigningMode: "unsigned-exception",
macosSigning: exception.macos,
windowsSigning: exception.windows,
artifactLabel: "UNSIGNED-PLATFORM",
distribution: exception.distribution,
warningRequired: true,
macosLocalExactTagFallback: exception.macosLocalExactTagFallback === true,
windowsDelivery: exception.windowsDelivery,
updaterFeedActivation: exception.updaterFeedActivation,
exception,
};
};
@@ -0,0 +1,47 @@
import { appendFile, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const args = process.argv.slice(2);
const value = (name) => {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
};
const policy = await loadDesktopReleasePolicy();
const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy);
const expectedTag = `v${packageInfo.version}`;
if (args.includes("--require-tag")) {
if (process.env.GITHUB_REF_TYPE !== "tag" || process.env.GITHUB_REF_NAME !== expectedTag) {
throw new Error(
`Desktop release policy must be resolved from tag ${expectedTag}; found ${process.env.GITHUB_REF_TYPE || "<missing>"} ${process.env.GITHUB_REF_NAME || "<missing>"}.`,
);
}
}
const outputs = {
version: resolution.version,
platform_signing_mode: resolution.platformSigningMode,
macos_signing: resolution.macosSigning,
windows_signing: resolution.windowsSigning,
artifact_label: resolution.artifactLabel,
distribution: resolution.distribution,
warning_required: String(resolution.warningRequired),
updater_signing_required: String(policy.updaterSigningRequired),
};
const githubOutput = value("--github-output");
if (githubOutput) {
await appendFile(githubOutput, `${Object.entries(outputs).map(([key, output]) => `${key}=${output}`).join("\n")}\n`);
}
console.log("Desktop release policy check passed.");
console.log(` version: ${resolution.version}`);
console.log(` platform signing mode: ${resolution.platformSigningMode}`);
console.log(` macOS: ${resolution.macosSigning}`);
console.log(` Windows: ${resolution.windowsSigning}`);
console.log(" updater signing: required");
@@ -2,22 +2,27 @@ import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = [];
const env = process.env;
const fullShaPattern = /^[0-9a-f]{40}$/i;
const expectedTag = `v${packageInfo.version}`;
const requiredSecretLikeEnv = [
"TAURI_SIGNING_PRIVATE_KEY",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
];
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true";
const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true";
const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE;
const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM;
const requiredUpdaterEnv = ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"];
const requiredMacosEnv = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID"];
const requiredWindowsEnv = ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD"];
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
@@ -40,7 +45,7 @@ const gitMaybe = (args) => {
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
assert(Boolean(env[name]), `${name} must be configured for desktop release readiness.`);
};
const validateBaseUrl = () => {
@@ -65,6 +70,43 @@ const validateBaseUrl = () => {
);
};
const validateClientServerUrl = () => {
const raw = env.VITE_DESKTOP_SERVER_URL;
requireEnv("VITE_DESKTOP_SERVER_URL");
if (!raw) return;
let url;
try {
url = new URL(raw);
} catch (error) {
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
};
const validateWindowsTimestampUrl = () => {
const raw = env.WINDOWS_TIMESTAMP_URL;
requireEnv("WINDOWS_TIMESTAMP_URL");
if (!raw) return;
let url;
try {
url = new URL(raw);
} catch (error) {
fail(`WINDOWS_TIMESTAMP_URL is invalid: ${error.message}`);
return;
}
assert(["http:", "https:"].includes(url.protocol), "WINDOWS_TIMESTAMP_URL must use HTTP or HTTPS.");
assert(url.username === "" && url.password === "", "WINDOWS_TIMESTAMP_URL must not include credentials.");
assert(!/[?&]token=/i.test(url.search), "WINDOWS_TIMESTAMP_URL must not include token query parameters.");
};
const headSha = gitMaybe(["rev-parse", "HEAD"]);
const exactTag = gitMaybe(["describe", "--tags", "--exact-match", "HEAD"]);
const status = gitMaybe(["status", "--porcelain"]);
@@ -79,19 +121,78 @@ if (headSha && env.VITE_BUILD_COMMIT) {
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
}
for (const name of requiredSecretLikeEnv) {
assert(
!(requiresMacosSigning && requiresWindowsSigning),
"macOS and Windows signing readiness must be checked in their native jobs.",
);
assert(
desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows",
"DESKTOP_RELEASE_PLATFORM must be macos or windows.",
);
assert(
desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode,
`DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`,
);
assert(
desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning,
"Release readiness requires REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.",
);
for (const name of requiredUpdaterEnv) {
requireEnv(name);
}
assert(
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
if (desktopPlatformSigningMode === "signed") {
assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE.");
assert(
(desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) ||
(desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning),
"Signed readiness must enable only the matching native platform signing requirement.",
);
}
if (desktopPlatformSigningMode === "unsigned-exception") {
assert(
desktopReleaseResolution.platformSigningMode === "unsigned-exception",
`v${packageInfo.version} has no approved unsigned platform release exception.`,
);
assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true.");
assert(
!requiresMacosSigning && !requiresWindowsSigning,
"Unsigned platform readiness must not claim Apple or Windows platform signing.",
);
if (desktopReleasePlatform === "macos") {
assert(process.platform === "darwin", "The macOS ad-hoc readiness check must run on macOS.");
}
if (desktopReleasePlatform === "windows") {
assert(process.platform === "win32", "The unsigned Windows readiness check must run on Windows.");
}
}
if (requiresMacosSigning) {
assert(process.platform === "darwin", "Signed macOS desktop release readiness must run on macOS.");
for (const name of requiredMacosEnv) {
requireEnv(name);
}
assert(
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
}
}
if (requiresWindowsSigning) {
assert(process.platform === "win32", "Signed Windows desktop release readiness must run on Windows.");
for (const name of requiredWindowsEnv) {
requireEnv(name);
}
validateWindowsTimestampUrl();
}
validateBaseUrl();
validateClientServerUrl();
if (failures.length > 0) {
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
+87 -3
View File
@@ -62,7 +62,7 @@ const verifyTauriConfig = async () => {
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
assert(
tauriConfig.bundle?.createUpdaterArtifacts === true,
"Tauri must create updater artifacts for signed desktop release builds.",
"Tauri must create signed updater artifacts for formal desktop release builds.",
);
assert(
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
@@ -362,8 +362,8 @@ const verifySourceSafety = async () => {
const verifyNotificationBoundary = async () => {
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
assert(source.includes('title: "CTMS 待办提醒"'), "Desktop notification title must stay generic.");
assert(source.includes('body: "有新的业务提醒待查看"'), "Desktop notification body must stay generic.");
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
};
@@ -387,11 +387,28 @@ const verifyUpdaterBoundary = async () => {
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
};
const verifyDesktopServerConfigurationBoundary = async () => {
const source = await readFile(resolve(sourceDir, "runtime/desktopServerConfig.ts"), "utf8");
assert(
source.includes("import.meta.env.VITE_DESKTOP_SERVER_URL"),
"Desktop server defaults must come from VITE_DESKTOP_SERVER_URL.",
);
assert(!source.includes("ctms.huapont.cn"), "The production CTMS origin must not be hard-coded in Desktop runtime source.");
assert(
source.includes("getStoredDesktopServerUrl() || getDefaultDesktopServerUrl()"),
"A manually stored Desktop server URL must override the build-time default.",
);
};
const verifyWorkflowGates = async () => {
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
const requiredScripts = [
"desktop:build:macos-release",
"desktop:build:macos-unsigned-release",
"desktop:build:windows-release",
"desktop:release-policy:check",
"desktop:release-provenance:create",
"desktop:update-feed:create",
"desktop:update-feed:check",
"desktop:release-readiness:check",
@@ -402,6 +419,44 @@ const verifyWorkflowGates = async () => {
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
}
const provenanceSource = await readFile(resolve(frontendDir, "scripts/create-desktop-release-provenance.mjs"), "utf8");
assert(provenanceSource.includes('"macos-first"'), "Release provenance must support the approved v0.1.0 macOS-first stage.");
assert(
provenanceSource.includes("withheld-pending-combined-verification"),
"Staged macOS provenance must record that updater feed activation is withheld.",
);
const windowsPendingNotice = await readFile(resolve(frontendDir, "desktop-release-windows-pending.txt"), "utf8");
assert(
windowsPendingNotice.includes("same immutable v0.1.0 tag") && windowsPendingNotice.includes("latest.json is intentionally withheld"),
"The staged v0.1.0 release must include an explicit Windows-pending and updater-feed notice.",
);
const releasePolicy = await readJson(resolve(frontendDir, "desktop-release-policy.json"));
assert(releasePolicy.schemaVersion === 1, "Desktop release policy schemaVersion must be 1.");
assert(
releasePolicy.defaultPlatformSigningMode === "signed",
"Desktop release policy must default future versions to signed platform releases.",
);
assert(releasePolicy.updaterSigningRequired === true, "Desktop release policy must require updater signing.");
const currentUnsignedException = releasePolicy.unsignedPlatformExceptions?.find(
(exception) => exception.version === packageInfo.version,
);
if (packageInfo.version === "0.1.0") {
assert(Boolean(currentUnsignedException), "Desktop release policy must record the approved v0.1.0 exception.");
assert(
currentUnsignedException?.macosLocalExactTagFallback === true &&
currentUnsignedException?.windowsDelivery === "deferred-same-tag" &&
currentUnsignedException?.updaterFeedActivation === "after-combined-platform-verification",
"The v0.1.0 exception must constrain local macOS staging, deferred Windows delivery, and updater feed activation.",
);
}
if (currentUnsignedException) {
assert(
currentUnsignedException.macos === "ad-hoc" && currentUnsignedException.windows === "unsigned",
`The v${packageInfo.version} exception must use macOS ad-hoc and unsigned Windows modes.`,
);
}
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [
"npm run version:check",
@@ -420,19 +475,46 @@ const verifyWorkflowGates = async () => {
}
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
assert(workflow.includes("VITE_DESKTOP_SERVER_URL"), "Client quality gates workflow must inject VITE_DESKTOP_SERVER_URL.");
assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs.");
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
const requiredReleaseWorkflowTokens = [
"runs-on: macos-latest",
"runs-on: windows-latest",
"REQUIRE_DESKTOP_SIGNING",
"REQUIRE_WINDOWS_SIGNING",
"REQUIRE_UPDATER_SIGNING",
"ALLOW_UNSIGNED_PLATFORM_RELEASE",
"DESKTOP_PLATFORM_SIGNING_MODE",
"desktop:release-policy:check",
"desktop:build:macos-unsigned-release",
"Signature=adhoc",
'"NotSigned"',
"UNSIGNED-PLATFORM",
"DESKTOP-RELEASE-PROVENANCE.json",
"--require-provenance",
"TAURI_SIGNING_PRIVATE_KEY",
"VITE_DESKTOP_SERVER_URL",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
"WINDOWS_CERTIFICATE",
"WINDOWS_CERTIFICATE_PASSWORD",
"WINDOWS_TIMESTAMP_URL",
"npm audit",
"npm run desktop:build:macos-release",
"npm run desktop:build:windows-release",
"Get-AuthenticodeSignature",
"EnhancedKeyUsageList",
"TimeStamperCertificate",
".nsis.zip",
"npm run desktop:update-feed:create",
"--platform-artifact",
"npm run desktop:update-feed:check",
"--require-platform windows-x86_64",
"npm run desktop:release-readiness:check",
"actions/download-artifact",
"actions/upload-artifact",
];
@@ -447,6 +529,7 @@ const verifyWorkflowGates = async () => {
"runs-on: windows-latest",
"VITE_BUILD_CHANNEL",
"VITE_BUILD_COMMIT",
"VITE_DESKTOP_SERVER_URL",
"npm run release:env:check",
"npm run version:check",
"npm run runtime:check",
@@ -505,6 +588,7 @@ await verifySourceSafety();
await verifyNotificationBoundary();
await verifySessionBoundary();
await verifyUpdaterBoundary();
await verifyDesktopServerConfigurationBoundary();
await verifyOnlyOfficeBoundary();
await verifyWorkflowGates();
@@ -2,9 +2,12 @@ import { access, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = [];
const args = process.argv.slice(2);
@@ -12,6 +15,8 @@ const optionValue = (name) => {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
};
const optionValues = (name) =>
args.reduce((result, argument, index) => (argument === name && args[index + 1] ? [...result, args[index + 1]] : result), []);
const feedPath = resolve(
frontendDir,
@@ -19,6 +24,17 @@ const feedPath = resolve(
);
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const requiresProvenance = args.includes("--require-provenance");
const provenancePath =
optionValue("--provenance") ||
process.env.DESKTOP_RELEASE_PROVENANCE ||
(artifactDir ? resolve(artifactDir, "DESKTOP-RELEASE-PROVENANCE.json") : undefined);
const requiredPlatforms = new Set([
"darwin-aarch64",
"darwin-x86_64",
...optionValues("--require-platform"),
]);
const platformPattern = /^(?:darwin|windows|linux)-(?:x86_64|aarch64|i686|armv7)$/;
const checksumManifestPath =
optionValue("--checksum-manifest") ||
process.env.DESKTOP_UPDATE_CHECKSUM_MANIFEST ||
@@ -86,6 +102,56 @@ const assertManifestEntries = async (checksums) => {
}
};
const verifyProvenance = async (checksums) => {
if (!requiresProvenance) return;
if (!provenancePath) {
fail("Desktop release provenance path is required.");
return;
}
let provenance;
try {
provenance = JSON.parse(await readFile(provenancePath, "utf8"));
} catch (error) {
fail(`Cannot read desktop release provenance ${provenancePath}: ${error.message}`);
return;
}
assert(provenance.schemaVersion === 1, "Desktop release provenance schemaVersion must be 1.");
assert(provenance.version === packageInfo.version, "Desktop release provenance version must match the package version.");
assert(provenance.tag === `v${packageInfo.version}`, "Desktop release provenance tag must match the package version.");
assert(/^[0-9a-f]{40}$/i.test(provenance.commit || ""), "Desktop release provenance must contain a full commit SHA.");
assert(
provenance.platformSigningMode === desktopReleaseResolution.platformSigningMode,
`Desktop release provenance platformSigningMode must be ${desktopReleaseResolution.platformSigningMode}.`,
);
assert(
provenance.platformSigning?.macos === desktopReleaseResolution.macosSigning,
`Desktop release provenance macOS signing must be ${desktopReleaseResolution.macosSigning}.`,
);
assert(
provenance.platformSigning?.windows === desktopReleaseResolution.windowsSigning,
`Desktop release provenance Windows signing must be ${desktopReleaseResolution.windowsSigning}.`,
);
assert(
provenance.updaterSigning === "required-and-verified",
"Desktop release provenance must record required-and-verified updater signing.",
);
assert(
provenance.artifactLabel === desktopReleaseResolution.artifactLabel,
`Desktop release provenance artifactLabel must be ${desktopReleaseResolution.artifactLabel}.`,
);
if (desktopReleaseResolution.warningRequired) {
assert(
Array.isArray(provenance.warnings) && provenance.warnings.length >= 2,
"Unsigned platform release provenance must include installation trust warnings.",
);
}
if (artifactDir) {
await assertChecksum(checksums, provenancePath, "desktop release provenance");
}
};
let feed;
try {
feed = JSON.parse(await readFile(feedPath, "utf8"));
@@ -96,6 +162,7 @@ try {
if (feed) {
const checksums = await readChecksumManifest();
await assertManifestEntries(checksums);
await verifyProvenance(checksums);
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"];
@@ -103,15 +170,16 @@ if (feed) {
assert(normalizedFeedVersion === packageInfo.version, `latest.json version must match package version ${packageInfo.version}.`);
assert(Boolean(feed.pub_date || feed.pubDate), "latest.json must include a publication date.");
assert(Boolean(darwinArm), "latest.json must include darwin-aarch64.");
assert(Boolean(darwinIntel), "latest.json must include darwin-x86_64.");
for (const platform of requiredPlatforms) {
assert(platformPattern.test(platform), `Required updater platform key is unsupported: ${platform}.`);
assert(Boolean(platforms[platform]), `latest.json must include ${platform}.`);
}
const entries = [
["darwin-aarch64", darwinArm],
["darwin-x86_64", darwinIntel],
];
const entries = Object.entries(platforms);
assert(entries.length > 0, "latest.json must include at least one platform entry.");
for (const [platform, entry] of entries) {
assert(platformPattern.test(platform), `latest.json contains an unsupported platform key: ${platform}.`);
const rawUrl = entry?.url;
const signature = entry?.signature;
assert(typeof signature === "string" && signature.length > 80, `${platform} must include an updater signature.`);
@@ -133,6 +201,12 @@ if (feed) {
if (expectedBaseUrl) {
assert(rawUrl.startsWith(expectedBaseUrl), `${platform} artifact URL must start with ${expectedBaseUrl}.`);
}
if (platform.startsWith("darwin-")) {
assert(url.pathname.endsWith(".app.tar.gz"), `${platform} must point to a macOS .app.tar.gz updater artifact.`);
}
if (platform === "windows-x86_64") {
assert(url.pathname.endsWith(".nsis.zip"), "windows-x86_64 must point to an NSIS .nsis.zip updater artifact.");
}
if (artifactDir) {
const artifactPath = resolve(artifactDir, basename(url.pathname));
+120 -4
View File
@@ -1,9 +1,14 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = [];
const allowedChannels = new Set(["dev", "main", "release", "local"]);
@@ -16,6 +21,38 @@ const commit = env.VITE_BUILD_COMMIT || "local";
const isCi = env.CI === "true" || env.GITHUB_ACTIONS === "true";
const isTagBuild = env.GITHUB_REF_TYPE === "tag";
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true";
const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true";
const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE;
const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM;
const isNativeDesktopRelease = Boolean(
desktopReleasePlatform ||
desktopPlatformSigningMode ||
requiresMacosSigning ||
requiresWindowsSigning ||
requiresUpdaterSigning,
);
const validateDesktopServerUrl = () => {
const raw = env.VITE_DESKTOP_SERVER_URL;
requireEnv("VITE_DESKTOP_SERVER_URL");
if (!raw) return;
let url;
try {
url = new URL(raw);
} catch (error) {
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
};
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
@@ -23,7 +60,19 @@ const assert = (condition, message) => {
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
assert(Boolean(env[name]), `${name} must be configured for this desktop release build.`);
};
const gitHead = () => {
try {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return undefined;
}
};
assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...allowedChannels].join(", ")}.`);
@@ -31,6 +80,7 @@ assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...al
if (isCi || isReleaseBuild) {
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
validateDesktopServerUrl();
}
if (env.GITHUB_SHA) {
@@ -40,18 +90,77 @@ if (env.GITHUB_SHA) {
);
}
if (isReleaseBuild && fullShaPattern.test(commit)) {
const headSha = gitHead();
assert(Boolean(headSha), "Release builds must be able to resolve the current Git commit.");
if (headSha) {
assert(commit === headSha, "VITE_BUILD_COMMIT must match the current Git HEAD for release builds.");
}
}
if (isTagBuild) {
assert(env.GITHUB_REF_NAME === semverTag, `Release tag must be ${semverTag}; found ${env.GITHUB_REF_NAME || "<missing>"}.`);
assert(channel === "release", "Release tag builds must set VITE_BUILD_CHANNEL=release.");
}
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
assert(
!(requiresMacosSigning && requiresWindowsSigning),
"macOS and Windows signing requirements must be checked in their native jobs.",
);
if (isNativeDesktopRelease) {
assert(isReleaseBuild, "Native desktop release settings may only be used for release builds.");
assert(
desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning,
"Formal desktop releases must set REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.",
);
assert(
desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode,
`DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`,
);
assert(
desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows",
"DESKTOP_RELEASE_PLATFORM must be macos or windows for native desktop release builds.",
);
if (isCi) {
assert(isTagBuild, "Signed desktop release candidate builds in CI must run from a release tag.");
assert(isTagBuild, "Native desktop release candidate builds in CI must run from a release tag.");
}
}
if (requiresUpdaterSigning) {
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
}
if (desktopPlatformSigningMode === "signed") {
assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE.");
assert(
(desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) ||
(desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning),
"Signed native release jobs must enable only their matching platform signing requirement.",
);
}
if (desktopPlatformSigningMode === "unsigned-exception") {
assert(
desktopReleaseResolution.platformSigningMode === "unsigned-exception",
`v${packageInfo.version} has no approved unsigned platform release exception.`,
);
assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true.");
assert(
!requiresMacosSigning && !requiresWindowsSigning,
"Unsigned platform exceptions must not claim Apple or Windows platform signing.",
);
if (desktopReleasePlatform === "macos") {
assert(process.platform === "darwin", "The macOS ad-hoc release exception must run on macOS.");
}
if (desktopReleasePlatform === "windows") {
assert(process.platform === "win32", "The unsigned Windows release exception must run on Windows.");
}
}
if (requiresMacosSigning) {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
requireEnv("APPLE_ID");
requireEnv("APPLE_PASSWORD");
requireEnv("APPLE_TEAM_ID");
@@ -64,6 +173,13 @@ if (env.REQUIRE_DESKTOP_SIGNING === "true") {
}
}
if (requiresWindowsSigning) {
assert(process.platform === "win32", "Signed Windows desktop release builds must run on Windows.");
requireEnv("WINDOWS_CERTIFICATE");
requireEnv("WINDOWS_CERTIFICATE_PASSWORD");
requireEnv("WINDOWS_TIMESTAMP_URL");
}
if (failures.length > 0) {
console.error(`Release build environment check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
+1 -1
View File
@@ -44,7 +44,7 @@
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwQjI5MUZFMjQ2NUM5QwpSV1NjWEViaUh5a0xBdE52U2Rhb29mZlVYZ3lnWGlDVGs1WE1RUGoyeWtSWW9pNzBnNW9qUGNaaAo="
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5MzI4NzBDRDlCRDUzMUIKUldRYlU3M1pESWN5V1ZaYzZOdVlhbk5xVmFlQW4zRmpGbmFUdWV1UUN3WFBsYU16clZJMzRaVjEK"
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { apiGet, apiPost, apiPut } from "./axios";
import type { NotificationItem } from "../types/notifications";
import type { GeneralNotificationItem } from "../types/notifications";
export interface DesktopNotificationSubscription {
enabled: boolean;
@@ -9,7 +9,7 @@ export interface DesktopNotificationSubscription {
export interface DesktopNotificationClaim {
claim_token?: string | null;
lease_expires_at?: string | null;
items: NotificationItem[];
items: GeneralNotificationItem[];
}
export const getDesktopNotificationSubscription = () =>
+7 -2
View File
@@ -11,18 +11,23 @@ describe("general notification API", () => {
it("loads an uncached recipient feed and updates read state", async () => {
const {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
} = await import("./notifications");
listGeneralNotifications("study-1", 8);
listGeneralNotifications("study-1", { limit: 8, unread_only: true });
markGeneralNotificationRead("study-1", "notification-1");
markAllGeneralNotificationsRead("study-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/feed",
{ params: { limit: 8 }, cache: false, disableRequestDedupe: true },
{ params: { limit: 8, unread_only: true }, cache: false, disableRequestDedupe: true },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/notification-1/read",
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/read-all",
);
});
});
+19 -6
View File
@@ -1,14 +1,27 @@
import { apiGet, apiPost } from "./axios";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
export const listGeneralNotifications = (studyId: string, limit = 10) =>
apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
params: { limit },
cache: false,
disableRequestDedupe: true,
});
export interface GeneralNotificationQuery {
skip?: number;
limit?: number;
category?: string;
unread_only?: boolean;
requires_action?: boolean;
}
export const listGeneralNotifications = (
studyId: string,
query: GeneralNotificationQuery = {},
) => apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
params: { limit: 10, ...query },
cache: false,
disableRequestDedupe: true,
});
export const markGeneralNotificationRead = (studyId: string, notificationId: string) =>
apiPost<GeneralNotificationItem>(
`/api/v1/studies/${studyId}/notifications/${notificationId}/read`,
);
export const markAllGeneralNotificationsRead = (studyId: string) =>
apiPost<void>(`/api/v1/studies/${studyId}/notifications/read-all`);
@@ -260,6 +260,9 @@ describe("ApiEndpointPermissions.vue", () => {
]);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:read")).toHaveLength(2);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2);
expect((wrapper.vm as any).displayOperations).toHaveLength(5);
expect((wrapper.vm as any).totalPermissionCount).toBe(3);
expect((wrapper.vm as any).filteredPermissionCount).toBe(3);
});
it("keeps contract fee permissions as one module section", () => {
@@ -6,7 +6,7 @@
<span class="toolbar-title">有效权限矩阵</span>
<el-tag size="small" effect="plain" type="info">只读</el-tag>
</div>
<span class="toolbar-result">显示 {{ filteredOperations.length }} / {{ displayOperations.length }} 项权限</span>
<span class="toolbar-result">显示 {{ filteredPermissionCount }} / {{ totalPermissionCount }} 项权限</span>
</div>
<div class="toolbar-filters">
<el-input
@@ -266,6 +266,12 @@ const filteredOperations = computed(() => {
return filtered;
});
const countUniquePermissions = (rows: DisplayOperation[]): number =>
new Set(rows.map((operation) => operation.operation_key)).size;
const totalPermissionCount = computed(() => countUniquePermissions(displayOperations.value));
const filteredPermissionCount = computed(() => countUniquePermissions(filteredOperations.value));
//
const spanMethod = ({ row, rowIndex, columnIndex }: { row: DisplayOperation; rowIndex: number; column: any; columnIndex: number }) => {
if (columnIndex === 0) {
+34
View File
@@ -296,6 +296,18 @@
<div v-else class="reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<div class="reminder-actions">
<el-dropdown-item command="notification:center" class="reminder-footer">
查看全部提醒
</el-dropdown-item>
<el-dropdown-item
v-if="headerReminderTotal > 0"
command="notification:read-all"
class="reminder-footer is-secondary"
>
全部已读
</el-dropdown-item>
</div>
</div>
</el-dropdown-menu>
</template>
@@ -2148,6 +2160,28 @@ useDesktopShortcuts(
text-align: center;
}
.reminder-actions {
display: grid;
grid-template-columns: 1fr 1fr;
margin-top: 6px;
border-top: 1px solid rgba(138, 155, 176, 0.2);
}
.reminder-actions .reminder-footer:only-child {
grid-column: 1 / -1;
}
.reminder-footer {
justify-content: center;
color: #2f6fed !important;
font-size: 12px;
font-weight: 700;
}
.reminder-footer.is-secondary {
color: #718096 !important;
}
.activity-button {
position: relative;
}
+22 -8
View File
@@ -15,6 +15,7 @@ const readTauriLibSource = () => readSource(resolve(__dirname, "../../src-tauri/
const readDesktopPreferencesSource = () => readSource(resolve(__dirname, "../views/DesktopPreferences.vue"));
const readDesktopServerSettingsSource = () => readSource(resolve(__dirname, "../views/DesktopServerSettings.vue"));
const readDesktopUpdateManagerSource = () => readSource(resolve(__dirname, "../session/desktopUpdateManager.ts"));
const readDesktopNotificationManagerSource = () => readSource(resolve(__dirname, "../session/desktopNotificationManager.ts"));
const readDesktopActivityCenterSource = () => readSource(resolve(__dirname, "../session/desktopActivityCenter.ts"));
const readProfileSettingsSource = () => readSource(resolve(__dirname, "../views/ProfileSettings.vue"));
const readLoginSource = () => readSource(resolve(__dirname, "../views/Login.vue"));
@@ -484,9 +485,10 @@ describe("desktop layout shell", () => {
expect(navigation).toContain("item.children?.length ? item.children : [item]");
});
it("keeps notification and update preferences lightweight", () => {
it("shows the real notification delivery path and keeps native notification access constrained", () => {
const serverSettings = readDesktopServerSettingsSource();
const preferences = readDesktopPreferencesSource();
const desktopNotificationManager = readDesktopNotificationManagerSource();
const desktopUpdateManager = readDesktopUpdateManagerSource();
const desktopLayout = readDesktopLayoutSource();
@@ -503,17 +505,25 @@ describe("desktop layout shell", () => {
expect(preferences).not.toContain("服务器连通性正常");
expect(preferences).not.toContain("服务器连接已确认");
expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })");
expect(preferences).not.toContain("系统通知已开启");
expect(preferences).not.toContain("系统通知已关闭");
expect(preferences).not.toContain("已授权");
expect(preferences).toContain("showNotificationPermissionTag");
expect(preferences).toContain("系统权限:{{ notificationPermissionText }}");
expect(preferences).toContain("服务端订阅");
expect(preferences).toContain("真实业务路径");
expect(preferences).toContain("授权并开启");
expect(preferences).toContain("重新检测权限");
expect(preferences).toContain("立即检查业务提醒");
expect(preferences).toContain("系统设置 → 通知 → CTMS");
expect(preferences).toContain("设置 → 系统 → 通知 → CTMS");
expect(preferences).toContain("showSystemNotificationProbe");
expect(preferences).toContain("sendDesktopNotificationTest");
expect(preferences).toContain("测试通知");
expect(preferences).not.toContain("通知诊断");
expect(preferences).toContain("发送测试通知");
expect(preferences).toContain("openProjectNotificationCenter");
expect(preferences).toContain("listenDesktopNotificationStatus");
expect(preferences).not.toContain("更新诊断");
expect(preferences).not.toContain("listenDesktopNotificationDiagnostics");
expect(preferences).not.toContain(["@tauri-apps", "plugin-notification"].join("/"));
expect(desktopNotificationManager).toContain("claimDesktopNotifications");
expect(desktopNotificationManager).toContain("acknowledgeDesktopNotifications");
expect(desktopNotificationManager).toContain('state: "permission-required"');
expect(desktopNotificationManager).toContain('state: deliveredIds.length ? "delivered" : "ready"');
expect(preferences).toContain("listenDesktopUpdateStatus");
expect(preferences).toContain("promptForPendingDesktopUpdate");
expect(preferences).toContain("当前已是最新版本");
@@ -603,6 +613,10 @@ describe("desktop layout shell", () => {
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell");
expect(styles).toContain("padding: 7px 10px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-pagination");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-radio-button__inner");
expect(styles).toContain("display: inline-flex;");
expect(styles).toContain("align-items: center;");
expect(styles).toContain("justify-content: center;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body");
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
});
+27 -1
View File
@@ -289,7 +289,18 @@
<div v-else class="header-reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<div class="header-reminder-actions">
<el-dropdown-item command="notification:center" class="header-reminder-footer">
查看全部提醒
</el-dropdown-item>
<el-dropdown-item
v-if="headerReminderTotal > 0"
command="notification:read-all"
class="header-reminder-footer is-secondary"
>
全部已读
</el-dropdown-item>
</div>
</div>
</el-dropdown-menu>
</template>
@@ -1848,6 +1859,21 @@ useDesktopShortcuts(
font-weight: 600;
}
.header-reminder-actions {
display: grid;
grid-template-columns: 1fr 1fr;
margin-top: 6px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
.header-reminder-actions .header-reminder-footer:only-child {
grid-column: 1 / -1;
}
.header-reminder-footer.is-secondary {
color: #64748b !important;
}
.collapse-btn {
border: 1px solid transparent;
color: var(--ctms-text-secondary);
@@ -10,11 +10,15 @@ describe("project notification feed contract", () => {
it("uses recipient notifications for both web and desktop reminder bells", () => {
expect(source).toContain("listGeneralNotifications");
expect(source).toContain("markGeneralNotificationRead");
expect(source).toContain("markAllGeneralNotificationsRead");
expect(source).toContain('/project/notifications');
expect(source).toContain("POLL_INTERVAL_MS = 60_000");
expect(source).toContain("PROJECT_NOTIFICATIONS_CHANGED_EVENT");
expect(webLayout).toContain("useProjectNotifications()");
expect(desktopLayout).toContain("useProjectNotifications()");
expect(webLayout).not.toContain("fetchOverdueAesCount");
expect(desktopLayout).not.toContain("fetchOverdueAesCount");
expect(webLayout).toContain('command="notification:center"');
expect(desktopLayout).toContain('command="notification:center"');
});
});
@@ -1,6 +1,10 @@
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { listGeneralNotifications, markGeneralNotificationRead } from "../api/notifications";
import {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
} from "../api/notifications";
import { useStudyStore } from "../store/study";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
@@ -32,7 +36,7 @@ export const useProjectNotifications = () => {
const route = useRoute();
const router = useRouter();
const headerRemindersLoading = ref(false);
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, items: [] });
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, total_count: 0, items: [] });
let requestId = 0;
let pollTimer: number | undefined;
@@ -40,12 +44,12 @@ export const useProjectNotifications = () => {
const studyId = study.currentStudy?.id;
const currentRequest = ++requestId;
if (!studyId || route.path.startsWith("/admin")) {
feed.value = { unread_count: 0, items: [] };
feed.value = { unread_count: 0, total_count: 0, items: [] };
return;
}
headerRemindersLoading.value = true;
try {
const { data } = await listGeneralNotifications(studyId, 10);
const { data } = await listGeneralNotifications(studyId, { limit: 10 });
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
} catch {
// 短时网络异常时保留当前内存中的提醒,避免角标在轮询失败时闪烁消失。
@@ -67,6 +71,17 @@ export const useProjectNotifications = () => {
const headerReminderBadgeValue = computed(() => headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value);
const handleReminderCommand = async (command: string) => {
if (command === "notification:center") {
await router.push("/project/notifications");
return;
}
if (command === "notification:read-all") {
const studyId = study.currentStudy?.id;
if (!studyId || feed.value.unread_count <= 0) return;
await markAllGeneralNotificationsRead(studyId);
await loadHeaderReminders();
return;
}
if (!command.startsWith("notification:")) return;
const notificationId = command.slice("notification:".length);
const item = feed.value.items.find((candidate) => candidate.id === notificationId);
@@ -75,10 +90,15 @@ export const useProjectNotifications = () => {
if (!item.read_at) {
item.read_at = new Date().toISOString();
feed.value.unread_count = Math.max(0, feed.value.unread_count - 1);
await markGeneralNotificationRead(studyId, item.id).catch(() => {
const response = await markGeneralNotificationRead(studyId, item.id).catch(() => {
item.read_at = null;
feed.value.unread_count += 1;
return null;
});
if (response?.data.resolved_at) {
feed.value.items = feed.value.items.filter((candidate) => candidate.id !== item.id);
feed.value.total_count = Math.max(0, feed.value.total_count - 1);
}
}
if (item.action_path?.startsWith("/")) await router.push(item.action_path);
};
+1
View File
@@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
readonly VITE_BUILD_COMMIT?: string;
readonly VITE_DESKTOP_SERVER_URL?: string;
}
interface ImportMeta {
+1 -1
View File
@@ -99,7 +99,7 @@ export const TEXT = {
dataUpdatedAt: "数据",
projectReminders: "提醒",
projectRemindersSubtitle: "业务通知与待办",
projectRemindersEmpty: "暂无未处理通知",
projectRemindersEmpty: "暂无业务提醒",
overdueAes: "逾期 AE",
overdueAesDesc: "需关注安全性事件处理时效",
overdueMonitoringIssues: "逾期监查问题",
+14
View File
@@ -0,0 +1,14 @@
import { createApp, defineComponent } from "vue";
import { describe, expect, it } from "vitest";
import { installElementPlus } from "./elementPlus";
describe("installElementPlus", () => {
it("registers radio group subcomponents used by the monitoring time selector", () => {
const app = createApp(defineComponent({ template: "<div />" }));
installElementPlus(app);
expect(app.component("ElRadioGroup")).toBeDefined();
expect(app.component("ElRadioButton")).toBeDefined();
});
});
+3 -1
View File
@@ -127,6 +127,8 @@ const components = [
];
export const installElementPlus = (app: App): void => {
for (const component of components) app.use(component);
for (const component of components) {
if (component.name) app.component(component.name, component);
}
app.use(ElLoading);
};
+7
View File
@@ -31,6 +31,7 @@ const EmailSettings = () => import("../views/admin/EmailSettings.vue");
const ProjectDetail = () => import("../views/admin/ProjectDetail.vue");
const ProjectOverview = () => import("../views/ia/ProjectOverview.vue");
const ProjectMilestones = () => import("../views/ia/ProjectMilestones.vue");
const ProjectNotifications = () => import("../views/ProjectNotifications.vue");
const FeeContracts = () => import("../views/fees/ContractFees.vue");
const FeeContractDetail = () => import("../views/fees/ContractFeeDetail.vue");
const DrugShipments = () => import("../views/ia/DrugShipments.vue");
@@ -149,6 +150,12 @@ const routes: RouteRecordRaw[] = [
component: ProjectMilestones,
meta: { title: TEXT.menu.projectMilestones, requiresStudy: true },
},
{
path: "project/notifications",
name: "ProjectNotifications",
component: ProjectNotifications,
meta: { title: "提醒中心", requiresStudy: true },
},
{
path: "fees/contracts",
name: "FeeContracts",
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
DESKTOP_SERVER_URL_KEY,
getDefaultDesktopServerUrl,
getDesktopServerUrl,
normalizeDesktopServerUrl,
setDesktopServerUrl,
@@ -31,6 +32,7 @@ const createMemoryStorage = (): Storage => {
};
beforeEach(() => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "");
Object.defineProperty(window, "localStorage", {
value: createMemoryStorage(),
configurable: true,
@@ -40,6 +42,7 @@ beforeEach(() => {
afterEach(() => {
window.localStorage.clear();
setTauriRuntime(false);
vi.unstubAllEnvs();
});
describe("desktop server config", () => {
@@ -70,6 +73,23 @@ describe("desktop server config", () => {
expect(listener).toHaveBeenCalledOnce();
});
it("uses the build-time default while allowing a persisted manual override", () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
expect(getDefaultDesktopServerUrl()).toBe("https://default.ctms.example.com/");
expect(getDesktopServerUrl()).toBe("https://default.ctms.example.com/");
setDesktopServerUrl("https://manual.ctms.example.com");
expect(getDesktopServerUrl()).toBe("https://manual.ctms.example.com/");
});
it("ignores an invalid build-time default", () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "http://public.ctms.example.com");
expect(getDefaultDesktopServerUrl()).toBeNull();
expect(getDesktopServerUrl()).toBeNull();
});
it("requires a server URL only inside the Tauri runtime", () => {
expect(shouldRequireDesktopServerUrl()).toBe(false);
setTauriRuntime(true);
@@ -77,4 +97,11 @@ describe("desktop server config", () => {
setDesktopServerUrl("https://ctms.example.com");
expect(shouldRequireDesktopServerUrl()).toBe(false);
});
it("does not require first-run configuration when a valid build-time default exists", () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
setTauriRuntime(true);
expect(shouldRequireDesktopServerUrl()).toBe(false);
});
});
+11 -2
View File
@@ -51,13 +51,22 @@ export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValida
return { ok: true, url: `${parsed.origin}/` };
};
export const getDesktopServerUrl = (): string | null => {
export const getDefaultDesktopServerUrl = (): string | null => {
const configured = import.meta.env.VITE_DESKTOP_SERVER_URL?.trim();
if (!configured) return null;
const result = normalizeDesktopServerUrl(configured);
return result.ok ? result.url : null;
};
const getStoredDesktopServerUrl = (): string | null => {
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
if (!stored) return null;
const result = normalizeDesktopServerUrl(stored);
return result.ok ? result.url : null;
};
export const getDesktopServerUrl = (): string | null => getStoredDesktopServerUrl() || getDefaultDesktopServerUrl();
export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl());
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
@@ -74,7 +83,7 @@ export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationRe
export const clearDesktopServerUrl = (): void => {
const previous = getDesktopServerUrl();
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
emitServerUrlChanged(previous, null);
emitServerUrlChanged(previous, getDesktopServerUrl());
};
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
+1
View File
@@ -2,6 +2,7 @@ export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./c
export {
clearDesktopServerUrl,
DESKTOP_SERVER_URL_CHANGED_EVENT,
getDefaultDesktopServerUrl,
getDesktopServerUrl,
hasDesktopServerUrl,
normalizeDesktopServerUrl,
+17 -1
View File
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getNotificationPermission, requestNotificationPermission, showSystemNotificationProbe } from "./notifications";
import {
getNotificationPermission,
requestNotificationPermission,
showSystemNotification,
showSystemNotificationProbe,
} from "./notifications";
const isPermissionGrantedMock = vi.hoisted(() => vi.fn());
const requestPermissionMock = vi.hoisted(() => vi.fn());
@@ -96,4 +101,15 @@ describe("notification runtime", () => {
body: "系统通知已可用",
});
});
it("keeps business details out of the desktop system notification", async () => {
enableTauriRuntime();
setBrowserNotificationPermission("granted");
expect(await showSystemNotification()).toBe(true);
expect(notificationDispatchMock).toHaveBeenCalledWith({
title: "CTMS 待办提醒",
body: "有新的业务提醒待查看",
});
});
});
+4 -4
View File
@@ -7,9 +7,9 @@ type StaticNotificationPayload = {
body: string;
};
const fileUpdateNotification: StaticNotificationPayload = {
title: "CTMS 文件更新",
body: "有新的文件版本待查看",
const businessReminderNotification: StaticNotificationPayload = {
title: "CTMS 待办提醒",
body: "有新的业务提醒待查看",
};
const notificationProbe: StaticNotificationPayload = {
@@ -53,7 +53,7 @@ const sendStaticSystemNotification = async (payload: StaticNotificationPayload):
};
export const showSystemNotification = async (): Promise<boolean> =>
sendStaticSystemNotification(fileUpdateNotification);
sendStaticSystemNotification(businessReminderNotification);
export const showSystemNotificationProbe = async (): Promise<boolean> =>
sendStaticSystemNotification(notificationProbe);
@@ -55,7 +55,11 @@ describe("desktop notification manager", () => {
showNotificationMock
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error("notification failed"));
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
const {
getDesktopNotificationStatus,
initDesktopNotificationManager,
triggerDesktopNotificationPoll,
} = await import("./desktopNotificationManager");
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
@@ -64,6 +68,11 @@ describe("desktop notification manager", () => {
expect(showNotificationMock).toHaveBeenCalledTimes(2);
expect(acknowledgeNotificationsMock).toHaveBeenCalledWith("claim-token", ["notification-1"]);
expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1);
expect(getDesktopNotificationStatus()).toMatchObject({
state: "error",
lastDeliveredCount: 0,
consecutiveFailures: 1,
});
});
it("does not acknowledge notifications when the system dispatch does not happen", async () => {
@@ -83,7 +92,11 @@ describe("desktop notification manager", () => {
it("does not claim notifications before operating system permission is granted", async () => {
getPermissionMock.mockResolvedValue("prompt");
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
const {
getDesktopNotificationStatus,
initDesktopNotificationManager,
triggerDesktopNotificationPoll,
} = await import("./desktopNotificationManager");
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
@@ -91,5 +104,54 @@ describe("desktop notification manager", () => {
expect(claimNotificationsMock).not.toHaveBeenCalled();
expect(acknowledgeNotificationsMock).not.toHaveBeenCalled();
expect(getDesktopNotificationStatus()).toMatchObject({
state: "permission-required",
lastDeliveredCount: 0,
consecutiveFailures: 0,
});
});
it("publishes real delivery progress without exposing claimed reminder content", async () => {
const {
getDesktopNotificationStatus,
initDesktopNotificationManager,
listenDesktopNotificationStatus,
triggerDesktopNotificationPoll,
} = await import("./desktopNotificationManager");
const states: string[] = [];
const unlisten = listenDesktopNotificationStatus((snapshot) => states.push(snapshot.state));
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
await vi.runOnlyPendingTimersAsync();
expect(states).toEqual(expect.arrayContaining(["idle", "polling", "delivered"]));
expect(getDesktopNotificationStatus()).toMatchObject({
state: "delivered",
lastDeliveredCount: 2,
consecutiveFailures: 0,
});
expect(getDesktopNotificationStatus().lastCheckedAt).toBeTruthy();
expect(getDesktopNotificationStatus().lastDeliveredAt).toBeTruthy();
unlisten();
});
it("reports a disabled server subscription without claiming reminders", async () => {
getSubscriptionMock.mockResolvedValue({ data: { enabled: false } });
const {
getDesktopNotificationStatus,
initDesktopNotificationManager,
triggerDesktopNotificationPoll,
} = await import("./desktopNotificationManager");
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
await vi.runOnlyPendingTimersAsync();
expect(claimNotificationsMock).not.toHaveBeenCalled();
expect(getDesktopNotificationStatus()).toMatchObject({
state: "subscription-disabled",
lastDeliveredCount: 0,
});
});
});
@@ -13,9 +13,39 @@ import {
const POLL_INTERVAL_MS = 60_000;
const MAX_BACKOFF_MS = 15 * 60_000;
export type DesktopNotificationDeliveryState =
| "idle"
| "signed-out"
| "permission-required"
| "subscription-disabled"
| "polling"
| "ready"
| "delivered"
| "error";
export interface DesktopNotificationStatusSnapshot {
state: DesktopNotificationDeliveryState;
lastCheckedAt?: string;
lastDeliveredAt?: string;
lastDeliveredCount: number;
consecutiveFailures: number;
}
let initialized = false;
let timer: number | null = null;
let failureCount = 0;
let status: DesktopNotificationStatusSnapshot = {
state: "idle",
lastDeliveredCount: 0,
consecutiveFailures: 0,
};
const statusListeners = new Set<(snapshot: DesktopNotificationStatusSnapshot) => void>();
const publishStatus = (patch: Partial<DesktopNotificationStatusSnapshot>) => {
status = { ...status, ...patch };
const snapshot = { ...status };
statusListeners.forEach((listener) => listener(snapshot));
};
const schedule = (delay: number) => {
if (timer !== null) window.clearTimeout(timer);
@@ -23,22 +53,41 @@ const schedule = (delay: number) => {
};
const poll = async () => {
timer = null;
if (!getToken()) {
failureCount = 0;
publishStatus({
state: "signed-out",
lastCheckedAt: new Date().toISOString(),
consecutiveFailures: 0,
});
schedule(POLL_INTERVAL_MS);
return;
}
try {
const permission = await getNotificationPermission();
if (permission !== "granted") {
failureCount = 0;
publishStatus({
state: "permission-required",
lastCheckedAt: new Date().toISOString(),
consecutiveFailures: 0,
});
schedule(POLL_INTERVAL_MS);
return;
}
const { data: subscription } = await getDesktopNotificationSubscription();
if (!subscription.enabled) {
failureCount = 0;
publishStatus({
state: "subscription-disabled",
lastCheckedAt: new Date().toISOString(),
consecutiveFailures: 0,
});
schedule(POLL_INTERVAL_MS);
return;
}
publishStatus({ state: "polling" });
const { data } = await claimDesktopNotifications();
const deliveredIds: string[] = [];
let deliveryFailed = false;
@@ -60,13 +109,36 @@ const poll = async () => {
throw new Error("desktop notification delivery failed");
}
failureCount = 0;
const checkedAt = new Date().toISOString();
publishStatus({
state: deliveredIds.length ? "delivered" : "ready",
lastCheckedAt: checkedAt,
lastDeliveredAt: deliveredIds.length ? checkedAt : status.lastDeliveredAt,
lastDeliveredCount: deliveredIds.length || status.lastDeliveredCount,
consecutiveFailures: 0,
});
schedule(POLL_INTERVAL_MS);
} catch {
failureCount += 1;
publishStatus({
state: "error",
lastCheckedAt: new Date().toISOString(),
consecutiveFailures: failureCount,
});
schedule(Math.min(POLL_INTERVAL_MS * 2 ** failureCount, MAX_BACKOFF_MS));
}
};
export const getDesktopNotificationStatus = (): DesktopNotificationStatusSnapshot => ({ ...status });
export const listenDesktopNotificationStatus = (
listener: (snapshot: DesktopNotificationStatusSnapshot) => void,
): (() => void) => {
statusListeners.add(listener);
listener(getDesktopNotificationStatus());
return () => statusListeners.delete(listener);
};
export const initDesktopNotificationManager = (): void => {
if (initialized || !isTauriRuntime()) return;
initialized = true;
@@ -84,4 +156,9 @@ export const stopDesktopNotificationManager = (): void => {
timer = null;
initialized = false;
failureCount = 0;
publishStatus({
state: "idle",
lastDeliveredCount: 0,
consecutiveFailures: 0,
});
};
+4
View File
@@ -930,9 +930,13 @@ body {
}
.desktop-workbench .desktop-route-shell .el-radio-button__inner {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 5px 10px;
border-radius: 5px;
line-height: 1;
}
.desktop-workbench .desktop-route-shell .el-drawer__header {
+4
View File
@@ -25,11 +25,15 @@ export interface GeneralNotificationItem {
action_path?: string | null;
source_type: string;
source_id: string;
requires_action: boolean;
due_at?: string | null;
read_at?: string | null;
resolved_at?: string | null;
created_at: string;
}
export interface GeneralNotificationFeed {
unread_count: number;
total_count: number;
items: GeneralNotificationItem[];
}
+14 -1
View File
@@ -1,5 +1,18 @@
import { describe, expect, it } from "vitest";
import { getContentDispositionFilename } from "./contentDisposition";
import { getContentDispositionFilename, getHttpHeaderString } from "./contentDisposition";
describe("getHttpHeaderString", () => {
it("keeps string header values and joins string arrays", () => {
expect(getHttpHeaderString("application/pdf")).toBe("application/pdf");
expect(getHttpHeaderString(["attachment", "filename=protocol.pdf"])).toBe("attachment, filename=protocol.pdf");
});
it("rejects non-string Axios header values", () => {
expect(getHttpHeaderString(200)).toBeUndefined();
expect(getHttpHeaderString(true)).toBeUndefined();
expect(getHttpHeaderString({ value: "application/pdf" })).toBeUndefined();
});
});
describe("getContentDispositionFilename", () => {
it("prefers the UTF-8 filename even when the ASCII fallback appears first", () => {
+8
View File
@@ -1,3 +1,11 @@
export const getHttpHeaderString = (value: unknown): string | undefined => {
if (typeof value === "string") return value;
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
return value.join(", ");
}
return undefined;
};
export const getContentDispositionFilename = (header?: string | null): string | null => {
if (!header) return null;
const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)?.[1];
+327 -34
View File
@@ -153,24 +153,84 @@
</section>
<section v-else-if="activeSectionId === 'notifications'" key="notifications" class="preference-panel">
<div class="preference-row">
<div>
<div class="row-title">系统通知</div>
<div class="row-desc">不含项目详情的文件更新提示</div>
<div class="notification-settings">
<div class="preference-row notification-primary-row">
<div>
<div class="row-title">系统通知</div>
<div class="row-desc">开启后定期领取服务端真实业务提醒系统弹窗不显示业务详情</div>
</div>
<div class="row-control">
<el-tag size="small" :type="notificationPermissionTagType">
系统权限{{ notificationPermissionText }}
</el-tag>
<el-switch
:model-value="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
aria-label="系统通知服务端订阅"
@change="onDesktopNotificationChange"
/>
</div>
</div>
<div class="row-control">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
<div class="notification-guidance" :class="`is-${notificationGuidanceTone}`">
<el-alert
:type="notificationGuidanceTone"
:title="notificationGuidanceTitle"
:description="notificationGuidanceDescription"
show-icon
:closable="false"
/>
<el-tag v-if="showNotificationPermissionTag" size="small" :type="notificationPermissionTagType">
{{ notificationPermissionText }}
</el-tag>
<el-button size="small" :loading="desktopNotificationTestLoading" @click="sendDesktopNotificationTest">
<el-icon><Bell /></el-icon>
<span>测试通知</span>
</el-button>
<div class="notification-status-grid" aria-label="系统通知投递状态">
<span>系统权限</span>
<strong>{{ notificationPermissionText }}</strong>
<span>服务端订阅</span>
<strong>{{ desktopNotificationsEnabled ? "已开启" : "未开启" }}</strong>
<span>真实业务路径</span>
<strong>{{ notificationDeliveryStatusText }}</strong>
<span>最近检查</span>
<strong>{{ notificationLastCheckedText }}</strong>
<span>最近投递</span>
<strong>{{ notificationLastDeliveredText }}</strong>
</div>
<p class="notification-privacy-note">
系统弹窗固定显示CTMS 待办提醒项目文件AE监查问题和受试者信息仅在登录后的提醒中心展示
</p>
<div class="notification-actions">
<el-button
v-if="notificationPermission === 'prompt'"
type="primary"
:loading="desktopNotificationLoading"
@click="authorizeAndEnableDesktopNotifications"
>
授权并开启
</el-button>
<el-button
v-else-if="notificationPermission === 'denied'"
:loading="desktopNotificationLoading"
@click="refreshDesktopNotificationPermission"
>
重新检测权限
</el-button>
<el-button
v-if="notificationPermission === 'granted' && desktopNotificationsEnabled"
:loading="desktopNotificationCheckLoading"
@click="checkDesktopBusinessNotifications"
>
立即检查业务提醒
</el-button>
<el-button
:disabled="notificationPermission === 'unsupported'"
:loading="desktopNotificationTestLoading"
@click="sendDesktopNotificationTest"
>
<el-icon><Bell /></el-icon>
<span>发送测试通知</span>
</el-button>
<el-button text @click="openProjectNotificationCenter">打开提醒中心</el-button>
</div>
</div>
</div>
</section>
@@ -253,7 +313,12 @@ import {
promptForPendingDesktopUpdate,
type DesktopUpdateStatusSnapshot,
} from "../session/desktopUpdateManager";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import {
getDesktopNotificationStatus,
listenDesktopNotificationStatus,
triggerDesktopNotificationPoll,
type DesktopNotificationStatusSnapshot,
} from "../session/desktopNotificationManager";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
@@ -309,8 +374,10 @@ const desktopShortcuts = ref<DesktopShortcutPreferences>(readDesktopShortcutPref
const capturingShortcutAction = ref<DesktopShortcutAction | null>(null);
const shortcutCaptureElement = ref<HTMLElement | null>(null);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const desktopNotificationStatus = ref<DesktopNotificationStatusSnapshot>(getDesktopNotificationStatus());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
let updateStatusUnlisten: (() => void) | undefined;
let notificationStatusUnlisten: (() => void) | undefined;
let connectionPendingTimer: number | undefined;
const themeOptions = [
{ value: "system" as const, label: "跟随系统", icon: Monitor },
@@ -345,19 +412,95 @@ const clientMetadataRows = computed(() => [
]);
const notificationPermissionText = computed(() => {
if (notificationPermission.value === "granted") return "已授权";
if (notificationPermission.value === "denied") return "已拒绝";
if (notificationPermission.value === "prompt") return "待授权";
return "不可用";
});
const showNotificationPermissionTag = computed(() => notificationPermission.value !== "granted");
const notificationPermissionTagType = computed(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
});
const desktopNotificationCheckLoading = computed(() => desktopNotificationStatus.value.state === "polling");
const formatNotificationTime = (value?: string) => {
if (!value) return "尚未检查";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "尚未检查";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
};
const notificationDeliveryStatusText = computed(() => {
const labels: Record<DesktopNotificationStatusSnapshot["state"], string> = {
idle: "等待桌面通知服务启动",
"signed-out": "登录后自动检查",
"permission-required": "等待系统授权",
"subscription-disabled": "等待开启服务端订阅",
polling: "正在调用真实业务提醒接口",
ready: "链路正常,暂无待投递提醒",
delivered: "已完成领取、系统投递和服务端确认",
error: "检查失败,将自动退避重试",
};
return labels[desktopNotificationStatus.value.state];
});
const notificationLastCheckedText = computed(() =>
formatNotificationTime(desktopNotificationStatus.value.lastCheckedAt),
);
const notificationLastDeliveredText = computed(() => {
if (!desktopNotificationStatus.value.lastDeliveredAt) return "本次运行尚无投递";
return `${formatNotificationTime(desktopNotificationStatus.value.lastDeliveredAt)} · ${desktopNotificationStatus.value.lastDeliveredCount}`;
});
const notificationSystemSettingsPath = computed(() => {
if (clientMetadata.platform === "macos") return "系统设置 → 通知 → CTMS → 打开“允许通知”";
if (clientMetadata.platform === "windows") return "设置 → 系统 → 通知 → CTMS → 打开通知";
return "系统通知设置 → CTMS → 允许通知";
});
const notificationGuidanceTone = computed<"success" | "warning" | "error" | "info">(() => {
if (notificationPermission.value === "unsupported") return "info";
if (notificationPermission.value === "denied") return "error";
if (notificationPermission.value === "prompt") return "warning";
if (desktopNotificationStatus.value.state === "error") return "error";
return desktopNotificationsEnabled.value ? "success" : "warning";
});
const notificationGuidanceTitle = computed(() => {
if (notificationPermission.value === "unsupported") return "当前客户端不支持系统通知";
if (notificationPermission.value === "denied") return "需要在系统设置中恢复 CTMS 通知权限";
if (notificationPermission.value === "prompt") return "允许 CTMS 发送系统通知";
if (!desktopNotificationsEnabled.value) return "系统权限已就绪,服务端订阅尚未开启";
if (desktopNotificationStatus.value.state === "error") return "真实业务提醒检查失败";
if (desktopNotificationStatus.value.state === "delivered") return "真实业务提醒已完成系统投递";
return "真实业务提醒已开启";
});
const notificationGuidanceDescription = computed(() => {
if (notificationPermission.value === "unsupported") return "请在 CTMS Desktop 中配置系统通知。";
if (notificationPermission.value === "denied") {
return `macOS/Windows 不会再次弹出授权框。请前往:${notificationSystemSettingsPath.value},返回后点击“重新检测权限”。`;
}
if (notificationPermission.value === "prompt") {
return "点击“授权并开启”后,系统会请求一次通知权限;允许后才会开启当前账号的服务端订阅。";
}
if (!desktopNotificationsEnabled.value) return "打开右侧开关后,客户端会定期领取并确认当前账号的待投递业务提醒。";
if (desktopNotificationStatus.value.state === "error") return "客户端会自动重试;也可以检查网络后立即重新检查。";
return "客户端每分钟检查一次;系统弹窗只作安全提示,业务处理仍在登录后的提醒中心完成。";
});
const desktopUpdateDescription = computed(() => {
const pending = desktopUpdateStatus.value.pendingUpdate;
if (desktopUpdateStatus.value.checking) return "正在检查签名更新";
@@ -621,38 +764,94 @@ const saveDesktopServerConfig = async () => {
};
const loadNotificationState = async () => {
notificationPermission.value = await getNotificationPermission();
try {
notificationPermission.value = await getNotificationPermission();
} catch {
notificationPermission.value = "unsupported";
}
try {
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
} catch {
desktopNotificationsEnabled.value = false;
}
triggerDesktopNotificationPoll();
};
const updateDesktopNotificationSubscription = async (enabled: boolean) => {
if (desktopNotificationLoading.value) return false;
const previousEnabled = desktopNotificationsEnabled.value;
desktopNotificationLoading.value = true;
try {
if (enabled) {
notificationPermission.value = await getNotificationPermission();
if (notificationPermission.value === "prompt") {
notificationPermission.value = await requestNotificationPermission();
}
if (notificationPermission.value !== "granted") {
ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`);
return false;
}
}
const { data } = await setDesktopNotificationSubscription(enabled);
desktopNotificationsEnabled.value = data.enabled;
triggerDesktopNotificationPoll();
return true;
} catch (error: any) {
desktopNotificationsEnabled.value = previousEnabled;
ElMessage.error(error?.response?.data?.detail || "系统通知设置失败");
return false;
} finally {
desktopNotificationLoading.value = false;
}
};
const onDesktopNotificationChange = async (value: string | number | boolean) => {
await updateDesktopNotificationSubscription(Boolean(value));
};
const authorizeAndEnableDesktopNotifications = async () => {
await updateDesktopNotificationSubscription(true);
};
const refreshDesktopNotificationPermission = async () => {
if (desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
if (value) {
notificationPermission.value = await requestNotificationPermission();
if (notificationPermission.value !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
notificationPermission.value = await getNotificationPermission();
if (notificationPermission.value === "granted") {
triggerDesktopNotificationPoll();
ElMessage.success("已检测到系统通知权限");
return;
}
const { data } = await setDesktopNotificationSubscription(Boolean(value));
desktopNotificationsEnabled.value = data.enabled;
if (data.enabled) triggerDesktopNotificationPoll();
} catch (error: any) {
desktopNotificationsEnabled.value = !Boolean(value);
ElMessage.error(error?.response?.data?.detail || "系统通知设置失败");
ElMessage.warning(`仍未检测到系统通知权限:${notificationSystemSettingsPath.value}`);
} catch {
notificationPermission.value = "unsupported";
ElMessage.error("系统通知权限检测失败");
} finally {
desktopNotificationLoading.value = false;
}
};
const checkDesktopBusinessNotifications = async () => {
if (desktopNotificationCheckLoading.value) return;
try {
notificationPermission.value = await getNotificationPermission();
if (notificationPermission.value !== "granted") {
ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`);
return;
}
if (!desktopNotificationsEnabled.value) {
await updateDesktopNotificationSubscription(true);
return;
}
triggerDesktopNotificationPoll();
ElMessage.info("已发起真实业务提醒检查");
} catch {
ElMessage.error("业务提醒检查失败");
}
};
const sendDesktopNotificationTest = async () => {
if (desktopNotificationTestLoading.value) return;
desktopNotificationTestLoading.value = true;
@@ -662,7 +861,7 @@ const sendDesktopNotificationTest = async () => {
notificationPermission.value = await requestNotificationPermission();
}
if (notificationPermission.value !== "granted") {
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
ElMessage.warning(`系统通知权限未开启${notificationSystemSettingsPath.value}`);
return;
}
const sent = await showSystemNotificationProbe();
@@ -678,6 +877,11 @@ const sendDesktopNotificationTest = async () => {
}
};
const openProjectNotificationCenter = () => {
emit("close-request");
void router.push("/project/notifications");
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
@@ -765,6 +969,9 @@ onMounted(() => {
updateStatusUnlisten = listenDesktopUpdateStatus((status) => {
desktopUpdateStatus.value = status;
});
notificationStatusUnlisten = listenDesktopNotificationStatus((status) => {
desktopNotificationStatus.value = status;
});
void loadNotificationState();
});
@@ -773,6 +980,7 @@ onBeforeUnmount(() => {
window.removeEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true });
clearConnectionPendingTimer();
updateStatusUnlisten?.();
notificationStatusUnlisten?.();
});
</script>
@@ -1090,6 +1298,7 @@ h3 {
.preferences-panel-enter-active .connection-alert,
.preferences-panel-enter-active .connection-diagnostic,
.preferences-panel-enter-active .preference-row,
.preferences-panel-enter-active .notification-guidance,
.preferences-panel-enter-active .shortcut-settings,
.preferences-panel-enter-active .metadata-panel {
animation: preference-card-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
@@ -1287,6 +1496,75 @@ h3 {
flex-shrink: 0;
}
/* =====================================================
* 系统通知授权与真实投递状态
* ===================================================== */
.notification-settings {
display: grid;
gap: 10px;
}
.notification-primary-row {
align-items: flex-start;
}
.notification-guidance {
display: grid;
gap: 14px;
padding: 14px 16px 16px;
border: 1px solid var(--pref-border-card);
border-radius: 10px;
background: var(--pref-bg-card);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 1px 1px rgba(15, 23, 42, 0.03);
}
.notification-guidance :deep(.el-alert) {
align-items: flex-start;
border-radius: 8px;
}
.notification-guidance :deep(.el-alert__description) {
line-height: 1.55;
}
.notification-status-grid {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: 8px 12px;
padding: 0 2px;
}
.notification-status-grid span {
color: var(--pref-text-muted);
font-size: 11.5px;
}
.notification-status-grid strong {
min-width: 0;
color: var(--pref-text-primary);
font-size: 11.5px;
font-weight: 620;
overflow-wrap: anywhere;
}
.notification-privacy-note {
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: #f7f9fc;
color: var(--pref-text-secondary);
font-size: 11.5px;
line-height: 1.55;
}
.notification-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
/* =====================================================
* 快捷键
* ===================================================== */
@@ -1528,6 +1806,7 @@ code {
:global([data-ctms-theme="dark"] .desktop-preferences .server-config-form),
:global([data-ctms-theme="dark"] .desktop-preferences .connection-diagnostic),
:global([data-ctms-theme="dark"] .desktop-preferences .preference-row),
:global([data-ctms-theme="dark"] .desktop-preferences .notification-guidance),
:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-settings),
:global([data-ctms-theme="dark"] .desktop-preferences .metadata-panel) {
border-color: var(--pref-border-card);
@@ -1547,6 +1826,19 @@ code {
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .notification-status-grid span) {
color: var(--pref-text-muted);
}
:global([data-ctms-theme="dark"] .desktop-preferences .notification-status-grid strong) {
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .notification-privacy-note) {
background: #0d1520;
color: var(--pref-text-secondary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .theme-segmented) {
@@ -1593,6 +1885,7 @@ code {
.preference-panel :deep(.el-button),
.connection-alert,
.connection-diagnostic,
.notification-guidance,
.connection-feedback-enter-active,
.connection-feedback-leave-active,
.preferences-header-enter-active,
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./ProjectNotifications.vue"), "utf8");
describe("project notification center contract", () => {
it("keeps read state separate from actionable business state", () => {
expect(source).toContain("已读不代表业务已完成");
expect(source).toContain("requires_action");
expect(source).not.toContain("include_resolved");
expect(source).toContain("markAllGeneralNotificationsRead");
expect(source).toContain('category.startsWith("VISIT_WINDOW_")');
expect(source).toContain('return "访视窗口"');
});
});
+739
View File
@@ -0,0 +1,739 @@
<template>
<section class="notification-page" aria-label="提醒中心已读不代表业务已完成">
<div class="notification-page__canvas">
<header class="notification-page__hero">
<div class="notification-page__header">
<div class="notification-page__intro">
<div class="notification-page__title-row">
<span class="notification-page__title-icon" aria-hidden="true">
<el-icon><Bell /></el-icon>
</span>
<h1>提醒中心</h1>
</div>
</div>
<div class="notification-page__summary" aria-label="提醒概览">
<div
class="notification-summary-item notification-summary-item--unread"
>
<span class="notification-summary-item__icon" aria-hidden="true">
<el-icon><BellFilled /></el-icon>
</span>
<span class="notification-summary-item__label">未读</span>
<strong>{{ feed.unread_count }}</strong>
</div>
<div class="notification-summary-item">
<span class="notification-summary-item__icon" aria-hidden="true">
<el-icon><Tickets /></el-icon>
</span>
<span class="notification-summary-item__label">当前</span>
<strong>{{ feed.total_count }}</strong>
</div>
</div>
<div class="notification-page__actions">
<el-button :icon="Refresh" :loading="loading" @click="loadNotifications">
刷新
</el-button>
<el-button
type="primary"
:icon="CircleCheck"
:disabled="feed.unread_count === 0"
:loading="markingAllRead"
@click="markAllRead"
>
全部标为已读
</el-button>
</div>
</div>
</header>
<section class="notification-content" aria-label="提醒列表">
<div class="notification-toolbar">
<div class="notification-toolbar__main">
<div class="notification-toolbar__title">
<strong>提醒列表</strong>
</div>
<el-radio-group v-model="filter" aria-label="提醒筛选" @change="resetAndLoad">
<el-radio-button value="active">当前提醒</el-radio-button>
<el-radio-button value="unread">仅未读</el-radio-button>
<el-radio-button value="action">仅待处理</el-radio-button>
</el-radio-group>
</div>
</div>
<div v-loading="loading" class="notification-list" aria-live="polite">
<button
v-for="item in feed.items"
:key="item.id"
type="button"
class="notification-row"
:class="{ 'is-read': item.read_at, 'is-resolved': item.resolved_at }"
@click="openNotification(item)"
>
<span class="notification-row__mark" :class="`is-${priorityTone(item.priority)}`"></span>
<span class="notification-row__main">
<span class="notification-row__heading">
<strong>{{ item.title }}</strong>
<el-tag size="small" effect="plain">{{ categoryLabel(item.category) }}</el-tag>
<el-tag
v-if="item.requires_action && !item.resolved_at"
size="small"
type="warning"
effect="light"
>
待处理
</el-tag>
<el-tag v-else-if="item.resolved_at" size="small" type="info" effect="plain">
已结束
</el-tag>
</span>
<span class="notification-row__message">{{ item.message }}</span>
<span v-if="item.due_at" class="notification-row__due">
截止{{ formatDateTime(item.due_at) }}
</span>
</span>
<span class="notification-row__meta">
<span>{{ formatDateTime(item.created_at) }}</span>
<small :class="{ 'is-unread': !item.read_at }">
<span v-if="!item.read_at" aria-hidden="true"></span>
{{ item.read_at ? "已读" : "未读" }}
</small>
</span>
</button>
<el-empty
v-if="!loading && feed.items.length === 0"
class="notification-empty"
:image-size="88"
>
<template #description>
<div class="notification-empty__copy">
<strong>{{ emptyState }}</strong>
</div>
</template>
<el-button
v-if="filter !== 'active'"
text
type="primary"
@click="showAllNotifications"
>
查看当前提醒
</el-button>
</el-empty>
</div>
<div v-if="feed.total_count > pageSize" class="notification-pagination">
<el-pagination
v-model:current-page="page"
background
layout="prev, pager, next"
:page-size="pageSize"
:total="feed.total_count"
@current-change="loadNotifications"
/>
</div>
</section>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Bell, BellFilled, CircleCheck, Refresh, Tickets } from "@element-plus/icons-vue";
import {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
type GeneralNotificationQuery,
} from "../api/notifications";
import {
notifyProjectNotificationsChanged,
PROJECT_NOTIFICATIONS_CHANGED_EVENT,
} from "../composables/useProjectNotifications";
import { useStudyStore } from "../store/study";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
type NotificationFilter = "active" | "unread" | "action";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const markingAllRead = ref(false);
const filter = ref<NotificationFilter>("active");
const page = ref(1);
const pageSize = 20;
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, total_count: 0, items: [] });
let requestId = 0;
const emptyState = computed(() => {
if (filter.value === "unread") return "没有未读提醒";
if (filter.value === "action") return "没有待处理事项";
return "当前暂无提醒";
});
const queryForFilter = (): GeneralNotificationQuery => {
const query: GeneralNotificationQuery = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
};
if (filter.value === "unread") query.unread_only = true;
if (filter.value === "action") query.requires_action = true;
return query;
};
const loadNotifications = async () => {
const studyId = study.currentStudy?.id;
const currentRequest = ++requestId;
if (!studyId) {
feed.value = { unread_count: 0, total_count: 0, items: [] };
return;
}
loading.value = true;
try {
const { data } = await listGeneralNotifications(studyId, queryForFilter());
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
} catch {
//
} finally {
if (currentRequest === requestId) loading.value = false;
}
};
const resetAndLoad = () => {
page.value = 1;
void loadNotifications();
};
const showAllNotifications = () => {
filter.value = "active";
resetAndLoad();
};
const markAllRead = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || markingAllRead.value) return;
markingAllRead.value = true;
try {
await markAllGeneralNotificationsRead(studyId);
notifyProjectNotificationsChanged();
await loadNotifications();
ElMessage.success("已将当前项目提醒全部标为已读");
} finally {
markingAllRead.value = false;
}
};
const openNotification = async (item: GeneralNotificationItem) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!item.read_at && !item.resolved_at) {
const response = await markGeneralNotificationRead(studyId, item.id).catch(() => null);
if (response) notifyProjectNotificationsChanged();
}
if (item.action_path?.startsWith("/")) {
await router.push(item.action_path);
return;
}
await loadNotifications();
};
const priorityTone = (priority: GeneralNotificationItem["priority"]) => {
if (priority === "URGENT") return "danger";
if (priority === "HIGH") return "warning";
return "info";
};
const categoryLabel = (category: string) => {
if (category.startsWith("RISK_") || category.includes("MONITORING")) return "风险时效";
if (category.startsWith("DOCUMENT_")) return "文件回执";
if (category.startsWith("MILESTONE_")) return "项目里程碑";
if (category.startsWith("VISIT_WINDOW_")) return "访视窗口";
if (category.startsWith("COLLABORATION_")) return "在线协作";
return "业务通知";
};
const formatDateTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const handleNotificationChange = () => { void loadNotifications(); };
watch(() => study.currentStudy?.id, resetAndLoad);
onMounted(() => {
void loadNotifications();
window.addEventListener("focus", handleNotificationChange);
window.addEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
});
onBeforeUnmount(() => {
window.removeEventListener("focus", handleNotificationChange);
window.removeEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
});
</script>
<style scoped>
.notification-page {
--notification-accent: var(--ctms-primary, #3f5d75);
--notification-accent-soft: rgba(63, 93, 117, 0.1);
min-height: 100%;
background: var(--ctms-bg-base, #f9fafb);
color: var(--ctms-text-main, #172033);
}
.notification-page__canvas {
width: min(100%, 1560px);
min-height: 100%;
margin: 0 auto;
display: grid;
grid-template-rows: auto minmax(320px, 1fr);
gap: 0;
align-content: stretch;
padding: clamp(10px, 1.4vw, 18px);
}
.notification-page__hero {
padding: 6px 4px 12px;
border-bottom: 1px solid var(--ctms-border-color, #e5e7eb);
}
.notification-page__header {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: auto minmax(260px, 1fr) auto;
align-items: center;
gap: 18px;
}
.notification-page__intro {
min-width: 0;
}
.notification-page__title-row {
display: flex;
align-items: center;
gap: 10px;
}
.notification-page__title-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid rgba(63, 93, 117, 0.12);
border-radius: 9px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 4px 12px rgba(38, 72, 98, 0.06);
color: var(--notification-accent);
font-size: 17px;
}
.notification-page__header h1 {
margin: 0;
color: var(--ctms-text-main, #172033);
font-size: 22px;
font-weight: 750;
letter-spacing: -0.02em;
line-height: 1.2;
}
.notification-page__actions {
display: flex;
flex: 0 0 auto;
gap: 10px;
}
.notification-page__summary {
display: flex;
align-items: center;
justify-content: start;
}
.notification-summary-item {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
padding: 0 18px;
border-right: 1px solid var(--ctms-border-color, #e5e7eb);
}
.notification-summary-item:first-child {
padding-left: 0;
}
.notification-summary-item:last-child {
padding-right: 0;
border-right: 0;
}
.notification-summary-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: var(--ctms-text-secondary, #64748b);
font-size: 15px;
}
.notification-summary-item--unread .notification-summary-item__icon {
color: var(--notification-accent);
}
.notification-summary-item__label {
min-width: 0;
color: var(--ctms-text-regular, #334155);
font-size: 12px;
font-weight: 650;
}
.notification-summary-item strong {
min-width: 1ch;
margin-left: 4px;
color: var(--ctms-text-main, #172033);
font-size: 19px;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.notification-content {
display: flex;
height: 100%;
min-height: 320px;
flex-direction: column;
background: transparent;
}
.notification-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: transparent;
color: var(--ctms-text-secondary, #64748b);
font-size: 12px;
}
.notification-toolbar__main {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.notification-toolbar__title {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.notification-toolbar__title strong {
color: var(--ctms-text-main, #172033);
font-size: 14px;
}
.notification-toolbar :deep(.el-radio-button__inner) {
min-height: 28px;
padding: 5px 12px;
font-weight: 550;
box-shadow: none;
}
.notification-list {
position: relative;
display: flex;
min-height: 240px;
flex: 1;
flex-direction: column;
padding: 0;
}
.notification-row {
width: 100%;
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
gap: 12px;
align-items: flex-start;
padding: 12px 10px;
border: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 160ms ease, box-shadow 160ms ease;
}
.notification-row:hover {
background: var(--ctms-neutral-100, #f5f7fa);
box-shadow: inset 3px 0 0 rgba(63, 93, 117, 0.24);
}
.notification-row:focus-visible {
outline: 2px solid var(--notification-accent);
outline-offset: -2px;
}
.notification-row.is-read {
opacity: 0.76;
}
.notification-row.is-resolved {
opacity: 0.58;
}
.notification-row__mark {
width: 8px;
height: 8px;
margin-top: 7px;
border-radius: 999px;
background: var(--notification-accent);
box-shadow: 0 0 0 4px var(--notification-accent-soft);
}
.notification-row__mark.is-danger {
background: var(--ctms-danger, #c24b4b);
box-shadow: 0 0 0 4px rgba(194, 75, 75, 0.1);
}
.notification-row__mark.is-warning {
background: var(--ctms-warning, #c58b2a);
box-shadow: 0 0 0 4px rgba(197, 139, 42, 0.11);
}
.notification-row__main,
.notification-row__meta {
display: flex;
flex-direction: column;
}
.notification-row__main { gap: 6px; }
.notification-row__heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.notification-row__heading strong {
color: var(--ctms-text-main, #172033);
font-size: 14px;
font-weight: 650;
}
.notification-row__message,
.notification-row__due,
.notification-row__meta {
color: var(--ctms-text-secondary, #64748b);
font-size: 12px;
}
.notification-row__message {
line-height: 1.6;
}
.notification-row__due {
color: #a16207;
font-weight: 550;
}
.notification-row__meta {
align-items: flex-end;
gap: 6px;
white-space: nowrap;
}
.notification-row__meta small {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 999px;
background: var(--ctms-neutral-100, #f5f7fa);
color: var(--ctms-text-secondary, #64748b);
font-size: 10px;
}
.notification-row__meta small.is-unread {
background: var(--notification-accent-soft);
color: var(--notification-accent);
font-weight: 650;
}
.notification-row__meta small > span {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.notification-empty {
display: flex;
min-height: 240px;
flex: 1;
align-items: center;
justify-content: center;
padding: 22px 16px 28px;
}
.notification-empty :deep(.el-empty__image) {
opacity: 0.72;
filter: saturate(0.55);
}
.notification-empty :deep(.el-empty__description) {
margin-top: 14px;
}
.notification-empty__copy { text-align: center; }
.notification-empty__copy strong {
color: var(--ctms-text-regular, #334155);
font-size: 14px;
font-weight: 650;
}
.notification-empty :deep(.el-empty__bottom) {
margin-top: 8px;
}
.notification-pagination {
display: flex;
justify-content: flex-end;
padding: 14px 20px 18px;
border-top: 1px solid var(--el-border-color-lighter);
}
:global([data-ctms-theme="dark"]) .notification-page {
--notification-accent-soft: rgba(143, 183, 212, 0.14);
background: var(--ctms-bg-base);
}
:global([data-ctms-theme="dark"]) .notification-page__title-icon {
border-color: rgba(143, 183, 212, 0.16);
background: rgba(17, 24, 39, 0.72);
}
:global(.desktop-workbench) .notification-page__canvas {
padding: 10px;
}
:global(.desktop-workbench) .notification-page__hero {
padding: 4px 2px 10px;
}
@media (max-width: 1100px) {
.notification-page__header {
grid-template-columns: 1fr auto;
}
.notification-page__summary {
grid-column: 1 / -1;
grid-row: 2;
}
}
@media (max-width: 767px) {
.notification-page__canvas {
grid-template-rows: auto minmax(320px, 1fr);
padding: 8px;
}
.notification-page__hero {
padding: 4px 2px 10px;
}
.notification-toolbar,
.notification-toolbar__main {
flex-direction: column;
align-items: stretch;
}
.notification-page__header {
grid-template-columns: 1fr;
gap: 10px;
}
.notification-page__actions {
width: 100%;
}
.notification-page__actions :deep(.el-button) {
flex: 1;
}
.notification-page__summary {
grid-column: 1;
grid-row: auto;
}
.notification-toolbar {
gap: 8px;
padding: 9px 10px;
}
.notification-toolbar__main {
gap: 12px;
}
.notification-toolbar :deep(.el-radio-group) {
display: flex;
width: 100%;
}
.notification-toolbar :deep(.el-radio-button) {
flex: 1;
}
.notification-toolbar :deep(.el-radio-button__inner) {
width: 100%;
padding-right: 8px;
padding-left: 8px;
}
.notification-row {
grid-template-columns: 8px minmax(0, 1fr);
padding: 16px 8px;
}
.notification-row__meta {
grid-column: 2;
align-items: flex-start;
flex-direction: row;
align-items: center;
}
}
@media (max-width: 520px) {
.notification-page__title-icon {
width: 34px;
height: 34px;
}
.notification-page__header h1 {
font-size: 21px;
}
.notification-summary-item {
min-height: 30px;
padding: 0 12px;
}
}
</style>
@@ -43,7 +43,7 @@
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
<span>{{ clientDescription(scope.row) }}</span>
<el-tag v-if="scope.row.grouped_count > 1" type="info" effect="plain" size="small">
合并 {{ scope.row.grouped_count }}
累计 {{ scope.row.grouped_count }} 次会话
</el-tag>
</div>
</template>
@@ -76,6 +76,10 @@ import { ElMessage } from "element-plus";
import { fetchUserLoginActivities } from "../../api/users";
import type { UserInfo, UserLoginActivity } from "../../types/api";
import { displayDateTime } from "../../utils/display";
import {
groupLoginActivitiesBySource,
type DisplayLoginActivity,
} from "./loginActivitySources";
const props = defineProps<{
modelValue: boolean;
@@ -95,8 +99,6 @@ const visibleProxy = computed({
set: (value: boolean) => emit("update:modelValue", value),
});
type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
const resolvedActivityStatus = (item: UserLoginActivity) => {
if (item.activity_status) return item.activity_status;
if (item.ended_at) return "ENDED";
@@ -120,30 +122,9 @@ const activityStatusDescription = (item: UserLoginActivity) => {
};
const clientDescription = (item: UserLoginActivity) =>
[item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--";
const activitySourceKey = (item: UserLoginActivity) => {
if (!item.login_ip) return `session:${item.id}`;
return [item.login_ip, item.client_type, item.client_platform || "", item.client_source || ""].join("|");
};
const sourceActivities = computed<DisplayLoginActivity[]>(() => {
const rows: DisplayLoginActivity[] = [];
const groupedHistory = new Map<string, DisplayLoginActivity>();
activities.value.forEach((item) => {
const row: DisplayLoginActivity = { ...item, grouped_count: 1 };
if (resolvedActivityStatus(item) === "ONLINE") {
rows.push(row);
return;
}
const key = activitySourceKey(item);
const existing = groupedHistory.get(key);
if (existing) {
existing.grouped_count += 1;
return;
}
groupedHistory.set(key, row);
rows.push(row);
});
return rows;
});
const sourceActivities = computed<DisplayLoginActivity[]>(() =>
groupLoginActivitiesBySource(activities.value, resolvedActivityStatus),
);
const displayActivities = computed<DisplayLoginActivity[]>(() =>
activityViewMode.value === "source"
? sourceActivities.value
@@ -22,7 +22,7 @@ describe("admin user login status", () => {
expect(drawer).toContain("resolvedActivityStatus");
expect(drawer).toContain("最近来源");
expect(drawer).toContain("全部会话");
expect(drawer).toContain("activitySourceKey");
expect(drawer).toContain("groupLoginActivitiesBySource");
expect(drawer).toContain("grouped_count");
expect(drawer).toContain("scope.row.ended_at");
expect(drawer).not.toContain("const isRecent");
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { UserLoginActivity } from "../../types/api";
import { activitySourceKey, groupLoginActivitiesBySource } from "./loginActivitySources";
const activity = (overrides: Partial<UserLoginActivity>): UserLoginActivity => ({
id: "session-1",
client_type: "web",
client_platform: "macos",
client_version: "0.1.0",
login_ip: "192.168.97.1",
login_at: "2026-07-16T02:23:04Z",
last_seen_at: "2026-07-16T03:08:32Z",
activity_status: "ENDED",
...overrides,
});
describe("login activity sources", () => {
it("uses only client type and a recorded IP as the source identity", () => {
expect(activitySourceKey(activity({ client_platform: "windows", client_source: "installer" }))).toBe(
"web|192.168.97.1",
);
expect(activitySourceKey(activity({ id: "legacy", login_ip: null }))).toBe("session:legacy");
});
it("merges online and historical sessions from the same client and IP", () => {
const rows = groupLoginActivitiesBySource(
[
activity({
id: "current",
login_at: "2026-07-16T07:22:42Z",
last_seen_at: "2026-07-16T07:53:00Z",
activity_status: "ONLINE",
ended_at: null,
}),
activity({ id: "history", ended_at: "2026-07-16T03:08:32Z" }),
activity({
id: "desktop",
client_type: "desktop",
login_at: "2026-07-15T08:42:02Z",
last_seen_at: "2026-07-16T07:53:42Z",
activity_status: "ONLINE",
ended_at: null,
}),
],
(item) => item.activity_status!,
);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ id: "desktop", grouped_count: 1, activity_status: "ONLINE" });
expect(rows[1]).toMatchObject({
id: "current",
grouped_count: 2,
login_at: "2026-07-16T02:23:04Z",
last_seen_at: "2026-07-16T07:53:00Z",
activity_status: "ONLINE",
ended_at: null,
});
});
it("does not merge sessions whose IP was not recorded", () => {
const rows = groupLoginActivitiesBySource(
[activity({ id: "legacy-1", login_ip: null }), activity({ id: "legacy-2", login_ip: null })],
(item) => item.activity_status!,
);
expect(rows).toHaveLength(2);
});
});
@@ -0,0 +1,56 @@
import type { UserLoginActivity } from "../../types/api";
export type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
type ActivityStatus = NonNullable<UserLoginActivity["activity_status"]>;
const activityTime = (value: string) => new Date(value).getTime();
export const activitySourceKey = (item: UserLoginActivity) => {
if (!item.login_ip) return `session:${item.id}`;
return [item.client_type, item.login_ip].join("|");
};
export const groupLoginActivitiesBySource = (
activities: UserLoginActivity[],
resolveStatus: (item: UserLoginActivity) => ActivityStatus,
): DisplayLoginActivity[] => {
const grouped = new Map<
string,
{ row: DisplayLoginActivity; firstLoginAt: string; hasOnlineSession: boolean }
>();
activities.forEach((item) => {
const key = activitySourceKey(item);
const status = resolveStatus(item);
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, {
row: { ...item, activity_status: status, grouped_count: 1 },
firstLoginAt: item.login_at,
hasOnlineSession: status === "ONLINE",
});
return;
}
existing.row.grouped_count += 1;
existing.hasOnlineSession ||= status === "ONLINE";
if (activityTime(item.login_at) < activityTime(existing.firstLoginAt)) {
existing.firstLoginAt = item.login_at;
}
if (activityTime(item.last_seen_at) > activityTime(existing.row.last_seen_at)) {
const groupedCount = existing.row.grouped_count;
existing.row = { ...item, activity_status: status, grouped_count: groupedCount };
}
});
return Array.from(grouped.values())
.map(({ row, firstLoginAt, hasOnlineSession }) => ({
...row,
login_at: firstLoginAt,
activity_status: hasOnlineSession ? "ONLINE" : row.activity_status,
ended_at: hasOnlineSession ? null : row.ended_at,
end_reason: hasOnlineSession ? null : row.end_reason,
}))
.sort((left, right) => activityTime(right.last_seen_at) - activityTime(left.last_seen_at));
};
@@ -381,7 +381,7 @@ import StateLoading from "../../components/StateLoading.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { getContentDispositionFilename } from "../../utils/contentDisposition";
import { getContentDispositionFilename, getHttpHeaderString } from "../../utils/contentDisposition";
import { prepareSaveFile } from "../../runtime";
import { detectFilePreviewKind, ONLYOFFICE_FILE_EXTENSIONS } from "../../utils/officePreview";
@@ -803,7 +803,8 @@ const previewVersion = async (version: DocumentVersion) => {
previewLoading.value = true;
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || version.mime_type || "application/octet-stream";
const contentType =
getHttpHeaderString(response.headers?.["content-type"]) || version.mime_type || "application/octet-stream";
const blob = new Blob([response.data], { type: contentType });
previewObjectUrl.value = URL.createObjectURL(blob);
previewUrl.value = previewObjectUrl.value;
@@ -823,8 +824,9 @@ const downloadVersion = async (version: DocumentVersion) => {
return;
}
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getContentDispositionFilename(response.headers?.["content-disposition"]) || suggestedName;
const contentType = getHttpHeaderString(response.headers?.["content-type"]) || "application/octet-stream";
const filename =
getContentDispositionFilename(getHttpHeaderString(response.headers?.["content-disposition"])) || suggestedName;
const blob = new Blob([response.data], { type: contentType });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob }, {}, destination);
} catch (e: any) { ElMessage.error(await getApiErrorMessage(e, TEXT.common.messages.downloadFailed)); }
@@ -840,10 +842,11 @@ const openVersion = async (version: DocumentVersion) => {
}
try {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getContentDispositionFilename(response.headers?.["content-disposition"])
|| getVersionFileName(version)
|| `document-${version.version_no || version.id}.bin`;
const contentType = getHttpHeaderString(response.headers?.["content-type"]) || "application/octet-stream";
const filename =
getContentDispositionFilename(getHttpHeaderString(response.headers?.["content-disposition"])) ||
getVersionFileName(version) ||
`document-${version.version_no || version.id}.bin`;
await openFileWithFeedback({
suggestedName: filename,
mimeType: contentType,
@@ -474,6 +474,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { getContentDispositionFilename, getHttpHeaderString } from "../../utils/contentDisposition";
interface MonitoringIssueRow {
id: string;
@@ -937,13 +938,6 @@ const removeIssue = async (row: MonitoringIssueRow) => {
}
};
const getFilename = (header?: string | null) => {
if (!header) return null;
const match = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(header);
if (!match) return null;
return decodeURIComponent(match[1] || match[2] || "");
};
const handleExportExcel = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !canListIssues.value) return;
@@ -975,8 +969,9 @@ const handleExportExcel = async () => {
}
const response = await exportMonitoringVisitIssues(studyId, params);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const contentType = getHttpHeaderString(response.headers?.["content-type"]) || "application/octet-stream";
const filename =
getContentDispositionFilename(getHttpHeaderString(response.headers?.["content-disposition"])) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType });
await saveFileWithFeedback(
{ suggestedName: filename, mimeType: contentType, data: blob },
+10 -5
View File
@@ -309,7 +309,8 @@ ctms_require_docker_compose() {
fi
}
# 安装/更新所需依赖。openssl、curl 仅在非 dev 环境要求(dev 用默认密钥、不做 RSA 生成)。
# 安装/更新所需依赖。所有环境都需要 openssl 生成独立的 ONLYOFFICE JWT 密钥;
# main/release 还会用它生成登录 RSA 密钥。
ctms_require_dependencies() {
local env="$1"
ctms_require_env "$env"
@@ -317,10 +318,7 @@ ctms_require_dependencies() {
ctms_require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1
ctms_require_command curl "Linux: sudo apt-get install -y curl | macOS: brew install curl" || missing=1
# openssl 仅非 dev 需要:dev 用 dev-secret 与空 RSA key,不生成密钥
if [[ "$env" != "dev" ]]; then
ctms_require_command openssl "Linux: sudo apt-get install -y openssl | macOS: brew install openssl" || missing=1
fi
ctms_require_command openssl "Linux: sudo apt-get install -y openssl | macOS: brew install openssl" || missing=1
[[ "$missing" -eq 0 ]] || ctms_fail "系统依赖不完整,请按上方提示安装后重试"
if ! docker compose version >/dev/null 2>&1; then
@@ -541,6 +539,7 @@ ctms_upsert_env_value() {
ctms_write_env_file() {
local project_name="$1" runtime_env="$2" login_key_id="$3"
local jwt_secret="$4" rsa_private_key="$5"
local onlyoffice_jwt_secret="$6" onlyoffice_instance_id="$7"
local allow_insecure_dev_login="false"
[[ "$runtime_env" == "development" ]] && allow_insecure_dev_login="true"
local temp_file
@@ -554,6 +553,12 @@ ctms_write_env_file() {
printf 'JWT_SECRET_KEY=%s\n' "$jwt_secret"
printf 'LOGIN_RSA_KEY_ID=%s\n' "$login_key_id"
printf 'LOGIN_RSA_PRIVATE_KEY=%s\n' "$rsa_private_key"
printf 'ONLYOFFICE_ENABLED=true\n'
printf 'ONLYOFFICE_JWT_SECRET=%s\n' "$onlyoffice_jwt_secret"
printf 'ONLYOFFICE_INTERNAL_URL=http://onlyoffice\n'
printf 'ONLYOFFICE_STORAGE_BASE_URL=http://backend:8000\n'
printf 'ONLYOFFICE_INSTANCE_ID=%s\n' "$onlyoffice_instance_id"
printf 'ONLYOFFICE_CONFIG_TTL_SECONDS=300\n'
} >> "$temp_file"
mv "$temp_file" "$CTMS_ENV_FILE"
+59 -6
View File
@@ -173,6 +173,10 @@ generate_jwt_secret() {
openssl rand -hex 32
}
generate_onlyoffice_instance_id() {
printf 'ctms-%s-%s' "$TARGET_ENV" "$(openssl rand -hex 8)"
}
# ── 地址解析 ─────────────────────────────────
resolve_base_url() {
[[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
@@ -221,6 +225,7 @@ confirm_install() {
row "健康检查地址" "$BASE_URL" "${CC_INFO}"
row ".env 文件" "$env_status"
row "pg_data 目录" "$pg_status"
row "ONLYOFFICE" "标准组件(自动安装)" "${CC_INFO}"
row "镜像构建" "$build_status"
row "数据库迁移" "$migrate_status"
ctms_box_bottom "${CC_PRIMARY}${C_BOLD}" "$w"
@@ -252,6 +257,7 @@ prepare_env_file() {
step "准备环境配置文件"
local project_name="$1" runtime_env="$2" login_key_id="$3"
local jwt_secret="" rsa_private_key=""
local onlyoffice_jwt_secret="" onlyoffice_instance_id=""
# 首次安装:无 .env,写全量。
if [[ ! -f "$ENV_FILE" ]]; then
@@ -265,7 +271,12 @@ prepare_env_file() {
rsa_private_key="$(generate_escaped_private_key)"
ok "已生成新的 LOGIN_RSA_PRIVATE_KEY"
fi
ctms_write_env_file "$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key"
onlyoffice_jwt_secret="$(generate_jwt_secret)"
onlyoffice_instance_id="$(generate_onlyoffice_instance_id)"
ok "已生成独立的 ONLYOFFICE JWT 密钥与实例标识"
ctms_write_env_file \
"$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key" \
"$onlyoffice_jwt_secret" "$onlyoffice_instance_id"
ok ".env 写入完成"
return
fi
@@ -310,6 +321,36 @@ prepare_env_file() {
fi
fi
# ONLYOFFICE 是标准组件:旧环境升级时自动启用并补齐独立安全配置。
if [[ "$(read_env_value ONLYOFFICE_ENABLED || true)" != "true" ]]; then
ctms_upsert_env_value ONLYOFFICE_ENABLED true; repaired=1
warn ".env 中 ONLYOFFICE 未启用,已作为标准组件启用"
fi
onlyoffice_jwt_secret="$(read_env_value ONLYOFFICE_JWT_SECRET || true)"
jwt_secret="$(read_env_value JWT_SECRET_KEY || true)"
if [[ ${#onlyoffice_jwt_secret} -lt 32 || "$onlyoffice_jwt_secret" == "$jwt_secret" ]]; then
onlyoffice_jwt_secret="$(generate_jwt_secret)"
ctms_upsert_env_value ONLYOFFICE_JWT_SECRET "$onlyoffice_jwt_secret"; repaired=1
warn ".env 缺少有效的独立 ONLYOFFICE_JWT_SECRET,已生成并补全"
fi
if [[ -z "$(read_env_value ONLYOFFICE_INSTANCE_ID || true)" ]]; then
onlyoffice_instance_id="$(generate_onlyoffice_instance_id)"
ctms_upsert_env_value ONLYOFFICE_INSTANCE_ID "$onlyoffice_instance_id"; repaired=1
warn ".env 缺少 ONLYOFFICE_INSTANCE_ID,已生成稳定实例标识"
fi
if [[ -z "$(read_env_value ONLYOFFICE_INTERNAL_URL || true)" ]]; then
ctms_upsert_env_value ONLYOFFICE_INTERNAL_URL http://onlyoffice; repaired=1
fi
if [[ -z "$(read_env_value ONLYOFFICE_STORAGE_BASE_URL || true)" ]]; then
ctms_upsert_env_value ONLYOFFICE_STORAGE_BASE_URL http://backend:8000; repaired=1
fi
if [[ -z "$(read_env_value ONLYOFFICE_CONFIG_TTL_SECONDS || true)" ]]; then
ctms_upsert_env_value ONLYOFFICE_CONFIG_TTL_SECONDS 300; repaired=1
fi
if [[ "$repaired" -eq 1 ]]; then
ok ".env 已补全缺失项"
else
@@ -320,7 +361,8 @@ prepare_env_file() {
# ── Docker 流程 ───────────────────────────────
run_compose_config() {
step "校验 Docker Compose 配置"
ctms_run_quiet "Docker Compose 配置语法校验" -- compose_cmd config
# config 会展开并输出 JWT/RSA 等敏感环境变量;即使启用 --verbose 也只做静默校验。
ctms_run_quiet "Docker Compose 配置语法校验" -- compose_cmd config --quiet
}
run_backend_init() {
@@ -338,14 +380,14 @@ run_build_and_start() {
if [[ "$SKIP_BUILD" -eq 1 ]]; then
step "启动服务(跳过镜像构建)"
if [[ "$TARGET_ENV" == "dev" ]]; then
ctms_run_quiet "启动开发容器并刷新后端/Nginx/Vite" -- compose_cmd up -d --force-recreate backend nginx frontend-dev
ctms_run_quiet "启动开发容器并刷新后端/ONLYOFFICE/Nginx/Vite" -- compose_cmd up -d --force-recreate backend onlyoffice nginx frontend-dev
else
ctms_run_quiet "启动容器" -- compose_cmd up -d
fi
else
step "构建镜像并启动服务"
if [[ "$TARGET_ENV" == "dev" ]]; then
CTMS_LIVE_OUTPUT=1 ctms_run_quiet "构建并刷新开发容器(首次较慢)" -- compose_cmd up -d --build --force-recreate backend nginx frontend-dev
CTMS_LIVE_OUTPUT=1 ctms_run_quiet "构建并刷新开发容器(首次较慢)" -- compose_cmd up -d --build --force-recreate backend onlyoffice nginx frontend-dev
else
CTMS_LIVE_OUTPUT=1 ctms_run_quiet "构建镜像并启动容器(首次较慢)" -- compose_cmd up -d --build
fi
@@ -366,7 +408,7 @@ check_container_status() {
step "检查容器运行状态"
local running
running="$(compose_cmd ps --services --filter status=running 2>/dev/null || true)"
local services=(db backend nginx)
local services=(db backend onlyoffice nginx)
[[ "$TARGET_ENV" != "dev" ]] || services+=(frontend-dev)
local svc
for svc in "${services[@]}"; do
@@ -398,6 +440,14 @@ if settings.LOGIN_RSA_KEY_ID != expected_key_id:
sys.exit("LOGIN_RSA_KEY_ID 不匹配")
if expected_env == "production" and settings.JWT_SECRET_KEY == "dev-secret":
sys.exit("生产环境不允许使用默认 JWT_SECRET_KEY")
if not settings.ONLYOFFICE_ENABLED:
sys.exit("ONLYOFFICE 必须作为标准组件启用")
if len(settings.ONLYOFFICE_JWT_SECRET or "") < 32:
sys.exit("ONLYOFFICE_JWT_SECRET 未正确配置")
if settings.ONLYOFFICE_JWT_SECRET == settings.JWT_SECRET_KEY:
sys.exit("ONLYOFFICE_JWT_SECRET 不得复用登录 JWT 密钥")
if not settings.ONLYOFFICE_INSTANCE_ID:
sys.exit("ONLYOFFICE_INSTANCE_ID 未配置")
if expect_rsa:
if not settings.LOGIN_RSA_PRIVATE_KEY:
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
@@ -463,6 +513,7 @@ run_health_checks() {
check_http_endpoint "/health"
check_http_endpoint "/readyz"
check_http_endpoint "/api/v1/auth/login-key"
check_http_endpoint "/onlyoffice/healthcheck" 120
if [[ "$TARGET_ENV" == "dev" ]]; then
check_http_endpoint "/" 60
fi
@@ -515,4 +566,6 @@ main() {
show_success
}
main "$@"
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+1 -2
View File
@@ -80,14 +80,13 @@ main() {
docker compose
-f "$CTMS_ROOT_DIR/docker-compose.dev.yaml"
-p ctms_dev
--profile office
)
ctms_step "校验开发 Compose 配置"
"${compose[@]}" config --quiet
ctms_ok "Compose 配置有效"
ctms_step "构建并启动 Office 预览服务"
ctms_step "重新构建并启动标准 ONLYOFFICE 服务"
"${compose[@]}" up -d --build --force-recreate backend onlyoffice nginx
ctms_step "验证运行状态"