85 Commits

Author SHA1 Message Date
Cheng Zhou 9c533b8c37 release(main): align v0.1.0 asset policy
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
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-07-17 12:41:00 +08:00
Cheng Zhou 795e0a75ca build(release): simplify v0.1.0 visible assets
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-17 12:40:16 +08:00
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 6b55c18610 refactor(frontend): 按需加载页面并清理未使用代码
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

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

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

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

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

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

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

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

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

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

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。
2026-07-16 14:58:47 +08:00
Cheng Zhou 1d26646a96 feat(collaboration): 完善在线文档协作与通知闭环
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

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

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

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

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

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

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

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

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

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

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

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
2026-07-16 14:14:54 +08:00
Cheng Zhou c68dddfc01 功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

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

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
2026-07-14 14:19:17 +08:00
Cheng Zhou 44db5db838 功能(文档与桌面):完善文件预览下载与客户端构建基线
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档
2026-07-13 18:40:48 +08:00
Cheng Zhou ab59476d10 feat(桌面与监控): 完善工作台导航和登录活动定位
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

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

- 更新桌面发布检查、运维文档和前后端测试覆盖
2026-07-13 16:03:20 +08:00
Cheng Zhou b26ebdda02 feat(工作台): 优化入口布局与连接安全状态 2026-07-13 11:04:15 +08:00
Cheng Zhou f2fa10c178 修复(权限管理):统一 PM 系统导航与权限校验 2026-07-10 22:12:59 +08:00
Cheng Zhou f11a5c84d9 feat(监控): 完善系统监控、登录状态与访问审计能力
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-10 17:12:13 +08:00
Cheng Zhou 400c9be3a7 fix(web): 修正工作台端侧标识
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-10 10:49:34 +08:00
Cheng Zhou 5ea9b24912 merge(dev): 同步审计访问上下文更新 2026-07-10 10:48:35 +08:00
Cheng Zhou aed628a49b fix(git): 跟踪原生图标资源 2026-07-10 10:47:54 +08:00
Cheng Zhou 0bbc125806 feat(admin): 完善审计访问上下文与后台布局 2026-07-09 17:10:42 +08:00
Cheng Zhou 878d1dc701 Merge branch 'dev' into codex/desktop-ui-stabilization
# Conflicts:
#	frontend/src/views/admin/Projects.test.ts
#	frontend/src/views/admin/Projects.vue
2026-07-09 15:57:08 +08:00
Cheng Zhou 0c187a35fa docs(desktop): 精简桌面端约束入口 2026-07-09 15:38:18 +08:00
Cheng Zhou 1dc10f569d feat(网页端): 完善登录后工作台与项目管理体验
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-09 14:59:06 +08:00
Cheng Zhou fd4208d06f 修复桌面端界面显示与稳定性问题 2026-07-09 13:53:01 +08:00
Cheng Zhou 0aceb1fd74 ci: 避免 Windows 安装器构建交互等待 2026-07-09 10:16:18 +08:00
Cheng Zhou 141fea674f ci: 修复 Windows 内测构建配置传参 2026-07-09 10:00:31 +08:00
Cheng Zhou 6b980f884a test: 兼容 Windows 换行的路由断言 2026-07-09 09:53:56 +08:00
Cheng Zhou 0841eb0f79 test: 兼容 Windows 换行的桌面布局断言 2026-07-09 09:48:03 +08:00
Cheng Zhou f4844aa8f1 fix: 修复桌面检查脚本的 Windows 路径判断 2026-07-09 09:38:55 +08:00
Cheng Zhou b03e30d0dd ci: 新增 Windows 桌面端内测构建 2026-07-09 09:30:44 +08:00
Cheng Zhou 0aff10fe0d 优化桌面端标签导航与后台交互
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-09 08:56:13 +08:00
Cheng Zhou 76f2d9f22a 功能(桌面端):增加在线辅助本地缓存
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-08 20:46:56 +08:00
Cheng Zhou b73f23c1eb style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标 2026-07-08 20:46:56 +08:00
Cheng Zhou e7b18758b2 优化桌面端界面布局 2026-07-08 20:46:56 +08:00
Cheng Zhou 84d5daebab feat(desktop): 收口桌面工作台视觉与活动反馈 2026-07-08 20:46:56 +08:00
Cheng Zhou 3a866accd9 完善桌面体验与系统通知收口 2026-07-08 20:46:56 +08:00
Cheng Zhou a17fa618cd 完善桌面端回归与安全边界复审 2026-07-08 20:46:56 +08:00
Cheng Zhou 0d03e1656a 补齐桌面端附件文件流回归 2026-07-08 20:46:56 +08:00
Cheng Zhou 1c1527a224 完善桌面端端到端回归收口 2026-07-08 20:46:56 +08:00
Cheng Zhou 360988de5e 完善桌面端发布稳定化门禁 2026-07-08 20:46:56 +08:00
Cheng Zhou b8c5c4123a feat(desktop): 支持桌面端三十天免登录 2026-07-08 20:46:56 +08:00
Cheng Zhou 8c8327df92 feat(desktop): 稳定桌面端界面与文件操作反馈
- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试
2026-07-08 20:46:56 +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 593c60c782 fix(auth): 支持无邮箱后缀时手动输入
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-01 14:37:32 +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 45ed1d7f36 fix(deploy): 增加部署更新实时进度 2026-07-01 11:22:39 +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 5ee84c97e8 fix(deploy): 修复数据库初始化复用旧镜像 2026-07-01 11:15:55 +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
Cheng Zhou 9cac75e85c 完善桌面端界面、发布检查与邮箱域名同步
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-01 10:49:31 +08:00
Cheng Zhou c923f887a0 完善桌面端交互体验与发布检查 2026-06-30 22:26:34 +08:00
Cheng Zhou 628ff8828b feat(desktop): implement phase 2 native capabilities
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-06-30 21:21:55 +08:00
Cheng Zhou 7c721d4e5c refactor(client): unify web and desktop release workflow
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-06-30 20:25:07 +08:00
Cheng Zhou d1a6c957f7 feat(desktop): implement phase 1 tauri client 2026-06-30 17:25:03 +08:00
Cheng Zhou 4654a812a0 docs: add desktop project plan 2026-06-30 16:45:30 +08:00
Cheng Zhou b283cf1e5c 优化镜像构建实时日志展示
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
2026-06-30 13:40:26 +08:00
Cheng Zhou 18daea7f2c 支持非 Git 部署目录滚动更新
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
2026-06-30 11:12:25 +08:00
Cheng Zhou 1078b67de6 同步主分支最新修改
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-06-30 10:44:05 +08:00
Cheng Zhou 668e719b6d 优化系统健康评分与缓存监测 2026-06-30 10:43:24 +08:00
Cheng Zhou b25055775e 完善邮件验证与密码重置安全流程 2026-06-30 09:44:24 +08:00
Cheng Zhou 6c2bcc59b2 调整账号注册与删除流程 2026-06-29 16:25:04 +08:00
Cheng Zhou e242e067f0 feat(ui): 美化忘记密码页面视觉风格
- 重构整体布局为极光渐变背景 + 居中毛玻璃卡片设计
- 新增浮动极光球光效(aurora-1/2/3)与旋转几何圆环装饰动效
- 卡片采用半透明磨砂玻璃(backdrop-filter: blur(40px)),阴影层次丰富
- 步骤指引改为带渐变编号圆圈的卡片列表,悬停有微动效
- 联系方式区块增加图标 + 悬停高亮交互
- 返回登录按钮增加光泽扫光动画与上浮效果
- 完整适配移动端响应式(max-width: 640px)
2026-06-29 11:31:10 +08:00
Cheng Zhou c7ca97a6d6 feat(ui): 优化登录与注册页面视觉风格
- 统一登录页(Login.vue)左侧品牌文案,移除虚假 AI 宣传,
  改为准确描述系统实际功能:项目全流程管理、合规核查与
  数据溯源、偏差与安全事件管理
- 重构注册页(Register.vue)为与登录页一致的明亮极光双栏布局:
  · 左侧:蓝紫渐变品牌面板 + 特性卡片 + 入场动效
  · 右侧:纯白背景 + 下划线风格输入框 + 蓝色 #2563eb 主色调
  · 步骤进度条、按钮、复选框配色全部对齐登录页风格
  · 小屏(< 900px)自动隐藏左侧品牌面板,右侧全屏适配
2026-06-29 11:22:56 +08:00
Cheng Zhou f94acebd1a Merge branch 'dev'
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-06-24 10:51:56 +08:00
Cheng Zhou f9a9f7eafe 环境配置统一以 .env 为单一事实来源,移除 .ctms-env
- 删除 .ctms-env:环境名由 .env 的 COMPOSE_PROJECT_NAME 反推
  (ctms_detect_env),健康检查地址改存 .env 的 CTMS_BASE_URL
- prepare_env_file 由“已存在则整体跳过”改为逐项探测补全:
  身份类缺失补目标值、密钥类缺失/空才生成、已有值一律不动
- resolve_effective_config 放宽为“冲突才致命”:身份类缺失交给
  补全,仅 COMPOSE_PROJECT_NAME/ENV 与目标环境冲突时报错停止
  (防止覆盖后孤立旧环境容器)
- 新增 ctms_env_has_key 区分“键缺失”与“键存在但空值”
  (dev 的 LOGIN_RSA_PRIVATE_KEY= 是合法空值)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:48:27 +08:00
Cheng Zhou b1c55abb10 Merge branch 'dev'
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-06-24 09:31:43 +08:00
Cheng Zhou 24d5b7445f 滚动更新新增 Git 访问方式选择(HTTPS/SSH)及工作区变更预检
- install.sh:
  - 滚动更新流程新增「选择 Git 访问方式」步骤(HTTPS 登录 / SSH 访问)
  - SSH 方式支持指定私钥文件路径或粘贴私钥内容(临时文件用后即删)
  - 仓库地址输入回填当前 remote(同协议时)作为默认值
  - 选环境后立即检测工作区变更,提供暂存/放弃/取消处理
  - 更新结束后按 HEAD 是否变化决定自动恢复暂存或提示手动恢复
- scripts/update.sh:
  - 新增 --ssh-key 参数,SSH 协议下通过 GIT_SSH_COMMAND -i 指定私钥
  - 新增 --skip-dirty-check 参数,供 install.sh 预检后透传
- scripts/common.sh:
  - ctms_ask_line 提示语改输出到 stderr,避免被命令替换吞掉

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:26:54 +08:00
Cheng Zhou 2df8d60172 手动选择环境与配置不一致时自动更新 .ctms-env
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:26:54 +08:00
Cheng Zhou b83c900109 环境菜单标题显示已识别的环境名
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:23:50 +08:00
Cheng Zhou 20b414e741 环境菜单默认项显示为 dev(默认) 格式,操作菜单移除标签
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:22:36 +08:00
Cheng Zhou a81647a4f8 环境菜单中对自动识别的默认项标注 [自动]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:18:11 +08:00
Cheng Zhou b72b5314a0 环境选择菜单始终显示,.ctms-env 配置作为默认选中项
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:14:05 +08:00
Cheng Zhou b126a759f0 有 .ctms-env 配置时直接展示部署环境,跳过选择菜单
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:09:46 +08:00
Cheng Zhou 05cd74fd43 修复滚动更新跳过环境选择的问题
.ctms-env 有配置时不再自动采用,改为作为菜单默认选中项,
用户仍可切换到其他环境。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:07:34 +08:00
Cheng Zhou e531ce75f9 Merge branch 'dev'
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-06-23 17:20:57 +08:00
Cheng Zhou 79d2ee344c 加固部署配置与敏感路径拦截
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
2026-06-22 15:35:25 +08:00
443 changed files with 75446 additions and 13186 deletions
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
env = dev
base-url = http://127.0.0.1:8888
+167
View File
@@ -0,0 +1,167 @@
name: Client Quality Gates
on:
pull_request:
paths:
- "AGENTS.md"
- "frontend/**"
- ".github/workflows/client-quality-gates.yml"
- ".github/workflows/desktop-release-candidate.yml"
- ".github/workflows/desktop-windows-internal.yml"
- "docs/branch-governance.md"
- "docs/desktop-project-plan.md"
- "docs/desktop-phase-1-design.md"
- "docs/desktop-phase-2-design.md"
- "docs/audits/desktop-release-stabilization-checklist.md"
- "docs/guides/client-release.md"
- "docs/guides/branch-maintenance-sop-zh.md"
push:
branches:
- dev
- main
- release
tags:
- "v*"
paths:
- "AGENTS.md"
- "frontend/**"
- ".github/workflows/client-quality-gates.yml"
- ".github/workflows/desktop-release-candidate.yml"
- ".github/workflows/desktop-windows-internal.yml"
- "docs/branch-governance.md"
- "docs/desktop-project-plan.md"
- "docs/desktop-phase-1-design.md"
- "docs/desktop-phase-2-design.md"
- "docs/audits/desktop-release-stabilization-checklist.md"
- "docs/guides/client-release.md"
- "docs/guides/branch-maintenance-sop-zh.md"
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
steps:
- name: Checkout
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: Install dependencies
run: npm ci
- name: Resolve build metadata
id: build-metadata
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
channel="release"
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
channel="${GITHUB_REF_NAME}"
else
channel="dev"
fi
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
- name: Check release build metadata
run: npm run release:env:check
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- 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
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
desktop:
name: macOS Desktop
runs-on: macos-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
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: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: npm ci
- name: Resolve build metadata
id: build-metadata
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
channel="release"
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
channel="${GITHUB_REF_NAME}"
else
channel="dev"
fi
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
- name: Check release build metadata
run: npm run release:env:check
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- 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: Build unsigned macOS application smoke artifact
run: npm run desktop:build:app
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
@@ -0,0 +1,456 @@
name: Desktop Release Candidate
on:
workflow_dispatch:
inputs:
artifact_base_url:
description: "Versioned HTTPS prefix for immutable desktop artifacts, for example https://ctms.example.com/desktop-updates/stable/v0.1.0/"
required: true
type: string
push:
tags:
- "v*"
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: macOS release candidate
needs: release-policy
runs-on: macos-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: 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 }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
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: bash
run: |
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
echo "Desktop release candidates must run from a vX.Y.Z tag."
exit 1
fi
expected_tag="v$(node -p "require('./package.json').version")"
if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then
echo "Release tag ${GITHUB_REF_NAME} does not match package version ${expected_tag}."
exit 1
fi
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- 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: 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: 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: |
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)"
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
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 "${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 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 }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-feed/*
if-no-files-found: error
@@ -0,0 +1,128 @@
name: Desktop Windows Internal Build
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: desktop-windows-internal-${{ github.ref }}
cancel-in-progress: false
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
steps:
- name: Checkout 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: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Resolve internal build metadata
id: build-metadata
shell: pwsh
run: |
if ($env:GITHUB_REF_TYPE -eq "tag") {
throw "Windows internal validation builds must run from a branch, not a release tag."
}
$channel = "dev"
if ($env:GITHUB_REF_NAME -in @("dev", "main", "release")) {
$channel = $env:GITHUB_REF_NAME
}
"channel=$channel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"commit=$env:GITHUB_SHA" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Install dependencies
run: npm ci
- name: Check release build metadata
run: npm run release:env:check
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- 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
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- name: Write Windows internal Tauri config
shell: pwsh
run: |
$config = @{
bundle = @{
createUpdaterArtifacts = $false
}
} | ConvertTo-Json -Depth 8
Set-Content -Path "tauri.windows.internal.conf.json" -Value $config -Encoding utf8
Get-Content "tauri.windows.internal.conf.json"
- name: Build unsigned Windows NSIS internal installer
timeout-minutes: 30
run: npm run desktop:build -- --config tauri.windows.internal.conf.json --bundles nsis --ci
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- name: Create Windows installer checksum manifest
shell: pwsh
run: |
$bundleDir = "src-tauri/target/release/bundle/nsis"
$installers = Get-ChildItem -Path $bundleDir -Filter "*.exe" -File
if ($installers.Count -eq 0) {
throw "No Windows NSIS EXE installer was produced."
}
$checksums = foreach ($installer in $installers) {
$hash = Get-FileHash -Path $installer.FullName -Algorithm SHA256
"$($hash.Hash.ToLowerInvariant()) $($installer.Name)"
}
$manifest = Join-Path $bundleDir "SHA256SUMS.txt"
$checksums | Set-Content -Path $manifest -Encoding utf8
Get-Content $manifest
- name: Upload Windows internal installer
uses: actions/upload-artifact@v4
with:
name: ctms-windows-internal-${{ steps.build-metadata.outputs.channel }}-${{ steps.build-metadata.outputs.commit }}
path: |
frontend/src-tauri/target/release/bundle/nsis/*.exe
frontend/src-tauri/target/release/bundle/nsis/SHA256SUMS.txt
if-no-files-found: error
+3
View File
@@ -53,6 +53,8 @@ pyrightconfig.json
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
frontend/src-tauri/target/
frontend/src-tauri/gen/schemas/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
@@ -82,4 +84,5 @@ backend/app/uploads/
# Git worktrees # Git worktrees
.worktrees/ .worktrees/
worktrees/
.install-logs/ .install-logs/
+62
View File
@@ -0,0 +1,62 @@
# Agent Instructions
桌面端项目计划书已完成并移除;当前桌面端执行约束以本文件为准。桌面端任务包括但不限于 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 兼容验证和已批准的 Windows 正式发布。不得重新按第一阶段空白项目初始化 Tauri,不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。
除非用户明确要求先调整本文件中的约束,否则不要实现离线功能、本地业务权威数据存储、内嵌后端服务、离线同步、本地优先工作流或新的阶段性桌面产品线。在线辅助本地缓存只允许作为已认证在线客户端的体验加速能力;处理桌面本地缓存、请求去重、条件请求、缓存诊断或缓存清理相关任务时,必须阅读并遵守 `docs/desktop-local-cache-plan.md`
处理前端或桌面端实现时,优先保持以下边界:
- 共享业务代码通过 `frontend/src/runtime/index.ts` 获取平台能力。
- Tauri API 仅允许出现在 `frontend/src/runtime/``frontend/src-tauri/` 或有明确记录的窄入口中。
- 本地缓存能力只能通过 `frontend/src/runtime/desktopDataCache.ts``frontend/src/runtime/index.ts``clientRuntime.dataCache` 和 API 客户端的受控入口暴露;业务模块不得直接使用 Tauri 存储 API、文件系统、SQLite、IndexedDB 或 Cache Storage 实现缓存。
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs``npm run runtime:check` 和桌面发布检查清单是否需要更新。
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
- 正式桌面客户端的默认 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 后补。面向安装用户的 GitHub Release 只保留 DMG 与只校验该安装包的 `SHA256SUMS.txt`,平台未签名风险和 Windows pending 状态写入 Release Notes。macOS updater 包及 `.sig`、完整 checksum、provenance、`UNSIGNED-PLATFORM` 与 Windows pending 证据必须保存在被 Git 忽略的私有发布目录,待 Windows `NotSigned` 制品和联合 feed 均验证通过后再发布到可匿名读取的独立 HTTPS updater 源;此前不得发布或替换生产 `latest.json`。该本地/分阶段例外不适用于后续版本。
## 分支与发布治理
处理代码提交、分支同步、版本晋级、正式发布或生产热修复前,必须先阅读:
- `docs/guides/branch-maintenance-sop-zh.md`
- `docs/branch-governance.md`
- 涉及网页端或桌面端客户端发布时,还需阅读 `docs/guides/client-release.md`
必须遵守以下规则:
- CTMS 网页端和桌面端属于同一个产品,共用 `dev``main``release` 分支,不创建 `web-dev``desktop-dev``web-release``desktop-release` 等长期平行分支。
- 默认晋级路径为短期任务分支进入 `dev`,再由 `dev` 晋级到 `main`,最后由 `main` 发布到 `release`
- Agent 创建分支时默认使用 `codex/<任务名称>`;分支必须从最新 `dev` 创建,并在合并到 `dev` 后删除。
- `codex/ctms-desktop` 是历史桌面端临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。
- 生产热修复从 `release` 创建,合并到 `release` 后必须依次回合并到 `main``dev`
- 网页端和桌面端必须使用同一个语义化版本号、正式标签和源代码提交。修改客户端版本时使用 `frontend/package.json` 中的 `version:set``version:check` 命令。
- 平台差异必须收敛在 `frontend/src/runtime/` 之后,不能通过长期分支或复制业务代码维护桌面差异。
- 未经用户明确要求,不执行提交、推送、合并、变基、打标签、删除分支或强制更新远程分支。
- 执行用户明确要求的 Git 操作前,先检查工作区和目标分支,只暂存本次任务相关文件,不覆盖或撤销用户已有改动。
- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行任何分支切换、提交、推送或变基前必须先说明目标基线,并等待用户明确指令。
- 分支治理规则发生变化时,必须同步更新上述治理文档,不能只修改 `AGENTS.md`
## 常用质量门禁
前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑:
```bash
cd frontend
npm run version:check
npm run release:env:check
npm run runtime:check
npm run desktop:release:check
npm run ui:contract
npm run type-check
npm run test:unit
npm run build
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 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证、Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、Release Notes、私有发布证据、完整 updater 校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。面向安装用户的 GitHub Release 可按精确版本策略精简为安装包与对应 checksum,但不得因此删除私有 updater 签名制品或验证证据。该例外不自动适用于后续版本。
+21 -3
View File
@@ -7,9 +7,9 @@
## 生产部署 ## 生产部署
- 生产入口:`docker-compose.yaml` - 生产入口:`docker-compose.yaml`
- 初始化方式:`docker compose run --rm backend-init` - 初始化方式:`docker compose run --rm --build backend-init`
- 启动方式:`docker compose up -d --build` - 启动方式:`docker compose up -d --build`
- 运行拓扑:`nginx``backend``db` - 运行拓扑:`nginx``backend``db``onlyoffice`
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API - 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql` - 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在 - 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
@@ -20,6 +20,7 @@
- `docker compose config` - `docker compose config`
- `curl -i http://127.0.0.1:8888/` - `curl -i http://127.0.0.1:8888/`
- `curl -i http://127.0.0.1:8888/health` - `curl -i http://127.0.0.1:8888/health`
- `curl -i http://127.0.0.1:8888/readyz`
## 账号与注册 ## 账号与注册
- 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建) - 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建)
@@ -38,12 +39,29 @@
## 访问方式 ## 访问方式
- 前端:`http://localhost:8888` - 前端:`http://localhost:8888`
- 后端 API:同域 `/api/v1/*` - 后端 API:同域 `/api/v1/*`
- `nginx` 负责托管前端静态资源,并将 `/api``/health` 转发到 `backend` - `nginx` 负责托管前端静态资源,并将 `/api``/health``/readyz` 转发到 `backend`
## macOS 桌面端开发
- 桌面端约束集中在 `AGENTS.md`:Tauri 在线客户端,不内嵌后端、不保存本地业务权威数据、不做离线同步;在线辅助缓存必须遵守 `docs/desktop-local-cache-plan.md`
- 开发启动:进入 `frontend/` 后执行 `npm run desktop:dev`
- 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`DMG 构建执行 `npm run desktop:bundle:dmg`
- 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health`
- 生产或非本地服务地址必须使用 HTTPS;本地开发允许 `http://localhost``http://127.0.0.1`
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `npm run version:check` 检查漂移。
- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`
## ONLYOFFICE 标准组件
- `./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` - 分支治理规范:`docs/branch-governance.md`
- 分支维护中文 SOP`docs/guides/branch-maintenance-sop-zh.md`
- 分支环境安装配置:`docs/guides/branch-environment-installation.md` - 分支环境安装配置:`docs/guides/branch-environment-installation.md`
- 发布检查清单:`docs/guides/release-checklist.md` - 发布检查清单:`docs/guides/release-checklist.md`
- 系统监测简易运维:`docs/guides/system-monitoring-operations.md`
## 本地配置 ## 本地配置
- 本地编辑器配置(如 `.vscode/`)不纳入版本库。 - 本地编辑器配置(如 `.vscode/`)不纳入版本库。
@@ -0,0 +1,94 @@
"""add email verification settings
Revision ID: 20260629_01
Revises: 20260608_01
Create Date: 2026-06-29 11:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260629_01"
down_revision: Union[str, None] = "20260608_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _uuid_column() -> sa.Column:
return sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
nullable=False,
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
smtp_security = postgresql.ENUM("NONE", "SSL", "STARTTLS", name="smtp_security", create_type=False)
email_purpose = postgresql.ENUM("REGISTER", "PASSWORD_RESET", name="email_verification_purpose", create_type=False)
smtp_security.create(bind, checkfirst=True)
email_purpose.create(bind, checkfirst=True)
if not _table_exists(inspector, "system_email_settings"):
op.create_table(
"system_email_settings",
_uuid_column(),
sa.Column("smtp_host", sa.String(length=255), nullable=False),
sa.Column("smtp_port", sa.Integer(), nullable=False),
sa.Column("smtp_security", smtp_security, server_default="SSL", nullable=False),
sa.Column("smtp_username", sa.String(length=255), nullable=False),
sa.Column("smtp_password_encrypted", sa.Text(), nullable=True),
sa.Column("sender_email", sa.String(length=255), nullable=False),
sa.Column("sender_name", sa.String(length=255), nullable=True),
sa.Column("allowed_register_domain", sa.String(length=255), nullable=False),
sa.Column("verification_code_ttl_minutes", sa.Integer(), nullable=False),
sa.Column("send_cooldown_seconds", sa.Integer(), nullable=False),
sa.Column("max_verify_attempts", sa.Integer(), nullable=False),
sa.Column("updated_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["updated_by"], ["users.id"]),
)
if not _table_exists(inspector, "email_verification_codes"):
op.create_table(
"email_verification_codes",
_uuid_column(),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("purpose", email_purpose, server_default="REGISTER", nullable=False),
sa.Column("code_hash", sa.String(length=255), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index(
"ix_email_verification_codes_email",
"email_verification_codes",
["email"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "email_verification_codes"):
op.drop_index("ix_email_verification_codes_email", table_name="email_verification_codes")
op.drop_table("email_verification_codes")
if _table_exists(inspector, "system_email_settings"):
op.drop_table("system_email_settings")
sa.Enum("REGISTER", "PASSWORD_RESET", name="email_verification_purpose").drop(bind, checkfirst=True)
sa.Enum("NONE", "SSL", "STARTTLS", name="smtp_security").drop(bind, checkfirst=True)
@@ -0,0 +1,40 @@
"""default multiple register email domains
Revision ID: 20260629_02
Revises: 20260629_01
Create Date: 2026-06-29 12:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260629_02"
down_revision: Union[str, None] = "20260629_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "system_email_settings" in inspector.get_table_names():
op.execute(
"UPDATE system_email_settings "
"SET allowed_register_domain = 'huapont.cn,qq.com' "
"WHERE allowed_register_domain IN ('huapont.cn', '@huapont.cn')"
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "system_email_settings" in inspector.get_table_names():
op.execute(
"UPDATE system_email_settings "
"SET allowed_register_domain = 'huapont.cn' "
"WHERE allowed_register_domain = 'huapont.cn,qq.com'"
)
@@ -0,0 +1,123 @@
"""email settings per register domain
Revision ID: 20260629_03
Revises: 20260629_02
Create Date: 2026-06-29 14:20:00.000000
"""
from typing import Sequence, Union
import uuid
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260629_03"
down_revision: Union[str, None] = "20260629_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def _normalize_domains(value: str | None) -> list[str]:
domains: list[str] = []
for item in (value or "huapont.cn,qq.com").replace("", ",").split(","):
domain = item.strip().lower().lstrip("@")
if domain and domain not in domains:
domains.append(domain)
return domains or ["huapont.cn", "qq.com"]
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "system_email_settings"):
return
if not _column_exists(inspector, "system_email_settings", "register_domain"):
op.add_column("system_email_settings", sa.Column("register_domain", sa.String(length=255), nullable=True))
rows = bind.execute(sa.text("SELECT * FROM system_email_settings ORDER BY created_at ASC")).mappings().all()
for row in rows:
domains = _normalize_domains(row.get("allowed_register_domain"))
primary_domain = domains[0]
bind.execute(
sa.text(
"UPDATE system_email_settings "
"SET register_domain = :register_domain, allowed_register_domain = :register_domain "
"WHERE id = :id"
),
{"register_domain": primary_domain, "id": row["id"]},
)
for domain in domains[1:]:
exists = bind.execute(
sa.text("SELECT 1 FROM system_email_settings WHERE register_domain = :register_domain"),
{"register_domain": domain},
).first()
if exists:
continue
bind.execute(
sa.text(
"""
INSERT INTO system_email_settings (
id, register_domain, smtp_host, smtp_port, smtp_security, smtp_username,
smtp_password_encrypted, sender_email, sender_name, allowed_register_domain,
verification_code_ttl_minutes, send_cooldown_seconds, max_verify_attempts,
updated_by, created_at, updated_at
) VALUES (
:id, :register_domain, :smtp_host, :smtp_port, :smtp_security, :smtp_username,
:smtp_password_encrypted, :sender_email, :sender_name, :allowed_register_domain,
:verification_code_ttl_minutes, :send_cooldown_seconds, :max_verify_attempts,
:updated_by, now(), now()
)
"""
),
{
"id": uuid.uuid4(),
"register_domain": domain,
"smtp_host": row["smtp_host"],
"smtp_port": row["smtp_port"],
"smtp_security": row["smtp_security"],
"smtp_username": row["smtp_username"],
"smtp_password_encrypted": row["smtp_password_encrypted"],
"sender_email": row["sender_email"],
"sender_name": row["sender_name"],
"allowed_register_domain": domain,
"verification_code_ttl_minutes": row["verification_code_ttl_minutes"],
"send_cooldown_seconds": row["send_cooldown_seconds"],
"max_verify_attempts": row["max_verify_attempts"],
"updated_by": row["updated_by"],
},
)
op.alter_column("system_email_settings", "register_domain", existing_type=sa.String(length=255), nullable=False)
op.create_unique_constraint(
"uq_system_email_settings_register_domain",
"system_email_settings",
["register_domain"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "system_email_settings"):
return
uniques = {item["name"] for item in inspector.get_unique_constraints("system_email_settings")}
if "uq_system_email_settings_register_domain" in uniques:
op.drop_constraint(
"uq_system_email_settings_register_domain",
"system_email_settings",
type_="unique",
)
if _column_exists(inspector, "system_email_settings", "register_domain"):
op.drop_column("system_email_settings", "register_domain")
@@ -0,0 +1,26 @@
"""add password reset email purpose
Revision ID: 20260629_04
Revises: 20260629_03
Create Date: 2026-06-29 16:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260629_04"
down_revision: Union[str, None] = "20260629_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE email_verification_purpose ADD VALUE IF NOT EXISTS 'PASSWORD_RESET'")
def downgrade() -> None:
# PostgreSQL enum values cannot be removed safely without recreating the type.
pass
@@ -0,0 +1,26 @@
"""add password reset link email purpose
Revision ID: 20260630_01
Revises: 20260629_04
Create Date: 2026-06-30 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260630_01"
down_revision: Union[str, None] = "20260629_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE email_verification_purpose ADD VALUE IF NOT EXISTS 'PASSWORD_RESET_LINK'")
def downgrade() -> None:
# PostgreSQL enum values cannot be removed safely without recreating the type.
pass
@@ -0,0 +1,74 @@
"""add desktop phase 2 notification and client metadata state
Revision ID: 20260630_02
Revises: 20260630_01
Create Date: 2026-06-30 21:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260630_02"
down_revision: Union[str, None] = "20260630_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("security_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
op.add_column("security_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
op.add_column("security_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
op.add_column("security_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
op.add_column("security_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
op.create_index(
"ix_security_log_client_created",
"security_access_logs",
["client_type", "client_version", "created_at"],
)
op.create_table(
"desktop_notification_subscriptions",
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False),
sa.Column("enabled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
op.create_table(
"desktop_notification_deliveries",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("distribution_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("claim_token", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["distribution_id"], ["distributions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id",
"distribution_id",
name="uq_desktop_notification_user_distribution",
),
)
op.create_index(
"ix_desktop_notification_claim",
"desktop_notification_deliveries",
["user_id", "delivered_at", "claimed_at"],
)
def downgrade() -> None:
op.drop_index("ix_desktop_notification_claim", table_name="desktop_notification_deliveries")
op.drop_table("desktop_notification_deliveries")
op.drop_table("desktop_notification_subscriptions")
op.drop_index("ix_security_log_client_created", table_name="security_access_logs")
for column in ("build_commit", "build_channel", "client_platform", "client_version", "client_type"):
op.drop_column("security_access_logs", column)
@@ -0,0 +1,46 @@
"""add access context to audit logs
Revision ID: 20260709_01
Revises: 20260630_02
Create Date: 2026-07-09 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260709_01"
down_revision: Union[str, None] = "20260630_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("audit_logs", sa.Column("client_ip", sa.String(45), nullable=True))
op.add_column("audit_logs", sa.Column("user_agent", sa.String(500), nullable=True))
op.add_column("audit_logs", sa.Column("client_type", sa.String(16), nullable=True))
op.add_column("audit_logs", sa.Column("client_version", sa.String(32), nullable=True))
op.add_column("audit_logs", sa.Column("client_platform", sa.String(16), nullable=True))
op.add_column("audit_logs", sa.Column("build_channel", sa.String(16), nullable=True))
op.add_column("audit_logs", sa.Column("build_commit", sa.String(64), nullable=True))
op.create_index("ix_audit_logs_operator_created", "audit_logs", ["operator_id", "created_at"])
op.create_index("ix_audit_logs_client_ip_created", "audit_logs", ["client_ip", "created_at"])
op.create_index("ix_audit_logs_client_source_created", "audit_logs", ["client_type", "created_at"])
def downgrade() -> None:
op.drop_index("ix_audit_logs_client_source_created", table_name="audit_logs")
op.drop_index("ix_audit_logs_client_ip_created", table_name="audit_logs")
op.drop_index("ix_audit_logs_operator_created", table_name="audit_logs")
for column in (
"build_commit",
"build_channel",
"client_platform",
"client_version",
"client_type",
"user_agent",
"client_ip",
):
op.drop_column("audit_logs", column)
@@ -0,0 +1,42 @@
"""add access context to permission access logs
Revision ID: 20260709_02
Revises: 20260709_01
Create Date: 2026-07-09 15:45:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260709_02"
down_revision: Union[str, None] = "20260709_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("permission_access_logs", sa.Column("user_agent", sa.String(500), nullable=True))
op.add_column("permission_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
op.add_column("permission_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
op.add_column("permission_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
op.add_column("permission_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
op.add_column("permission_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
op.create_index("ix_perm_log_ip_created", "permission_access_logs", ["ip_address", "created_at"])
op.create_index("ix_perm_log_client_source_created", "permission_access_logs", ["client_type", "created_at"])
def downgrade() -> None:
op.drop_index("ix_perm_log_client_source_created", table_name="permission_access_logs")
op.drop_index("ix_perm_log_ip_created", table_name="permission_access_logs")
for column in (
"build_commit",
"build_channel",
"client_platform",
"client_version",
"client_type",
"user_agent",
):
op.drop_column("permission_access_logs", column)
@@ -0,0 +1,35 @@
"""add request headers to permission access logs
Revision ID: 20260709_03
Revises: 20260709_02
Create Date: 2026-07-09 16:58:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260709_03"
down_revision: Union[str, None] = "20260709_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REQUEST_HEADERS_TYPE = sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
op.add_column(
"permission_access_logs",
sa.Column("request_headers", REQUEST_HEADERS_TYPE, nullable=True),
)
def downgrade() -> None:
op.drop_column("permission_access_logs", "request_headers")
@@ -0,0 +1,35 @@
"""add request headers to security access logs
Revision ID: 20260709_04
Revises: 20260709_03
Create Date: 2026-07-09 17:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260709_04"
down_revision: Union[str, None] = "20260709_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REQUEST_HEADERS_TYPE = sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
op.add_column(
"security_access_logs",
sa.Column("request_headers", REQUEST_HEADERS_TYPE, nullable=True),
)
def downgrade() -> None:
op.drop_column("security_access_logs", "request_headers")
@@ -0,0 +1,40 @@
"""add request snapshots to access logs
Revision ID: 20260709_05
Revises: 20260709_04
Create Date: 2026-07-09 18:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260709_05"
down_revision: Union[str, None] = "20260709_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REQUEST_SNAPSHOT_TYPE = sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
op.add_column(
"permission_access_logs",
sa.Column("request_snapshot", REQUEST_SNAPSHOT_TYPE, nullable=True),
)
op.add_column(
"security_access_logs",
sa.Column("request_snapshot", REQUEST_SNAPSHOT_TYPE, nullable=True),
)
def downgrade() -> None:
op.drop_column("security_access_logs", "request_snapshot")
op.drop_column("permission_access_logs", "request_snapshot")
@@ -0,0 +1,67 @@
"""add monitoring event identity and persisted security classification
Revision ID: 20260710_01
Revises: 20260709_05
Create Date: 2026-07-10 09:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260710_01"
down_revision: Union[str, None] = "20260709_05"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("permission_access_logs", sa.Column("request_id", sa.String(36), nullable=True))
op.add_column("security_access_logs", sa.Column("request_id", sa.String(36), nullable=True))
op.add_column("security_access_logs", sa.Column("category", sa.String(30), nullable=True))
op.add_column("security_access_logs", sa.Column("severity", sa.String(16), nullable=True))
op.create_index("ix_perm_log_request_id", "permission_access_logs", ["request_id"])
op.create_index("ix_security_log_request_id", "security_access_logs", ["request_id"])
op.create_index("ix_security_log_category_created", "security_access_logs", ["category", "created_at"])
op.create_index("ix_security_log_severity_created", "security_access_logs", ["severity", "created_at"])
op.execute(
"""
UPDATE security_access_logs
SET category = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'PROBE'
WHEN status_code >= 500 THEN 'SERVER_ERROR'
WHEN auth_status = 'INVALID_TOKEN' THEN 'INVALID_TOKEN'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'ANONYMOUS_API'
WHEN status_code = 404 THEN 'NOT_FOUND_NOISE'
ELSE 'OTHER'
END,
severity = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'CRITICAL'
WHEN status_code >= 500 THEN 'HIGH'
WHEN auth_status = 'INVALID_TOKEN' THEN 'MEDIUM'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'MEDIUM'
ELSE 'LOW'
END
"""
)
def downgrade() -> None:
op.drop_index("ix_security_log_severity_created", table_name="security_access_logs")
op.drop_index("ix_security_log_category_created", table_name="security_access_logs")
op.drop_index("ix_security_log_request_id", table_name="security_access_logs")
op.drop_index("ix_perm_log_request_id", table_name="permission_access_logs")
op.drop_column("security_access_logs", "severity")
op.drop_column("security_access_logs", "category")
op.drop_column("security_access_logs", "request_id")
op.drop_column("permission_access_logs", "request_id")
@@ -0,0 +1,93 @@
"""add source location hourly snapshots
Revision ID: 20260710_02
Revises: 20260710_01
Create Date: 2026-07-10 15:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260710_02"
down_revision: Union[str, None] = "20260710_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"source_location_snapshots",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("ip_hash", sa.String(length=64), nullable=False),
sa.Column("user_hash", sa.String(length=64), nullable=False, server_default=""),
sa.Column("country", sa.String(length=100), nullable=False, server_default=""),
sa.Column("country_code", sa.String(length=16), nullable=False, server_default=""),
sa.Column("province", sa.String(length=100), nullable=False, server_default=""),
sa.Column("region_code", sa.String(length=24), nullable=False, server_default=""),
sa.Column("city", sa.String(length=100), nullable=False, server_default=""),
sa.Column("isp", sa.String(length=160), nullable=False, server_default=""),
sa.Column("location", sa.String(length=320), nullable=False, server_default=""),
sa.Column("longitude", sa.Float(), nullable=True),
sa.Column("latitude", sa.Float(), nullable=True),
sa.Column("accuracy_level", sa.String(length=16), nullable=False, server_default="unknown"),
sa.Column("allowed_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("denied_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("security_event_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("high_risk_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("auth_failure_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("bucket_time", "ip_hash", "user_hash", name="uq_source_location_bucket_identity"),
)
op.create_index("ix_source_location_bucket", "source_location_snapshots", ["bucket_time"])
op.create_index(
"ix_source_location_country_bucket",
"source_location_snapshots",
["country_code", "bucket_time"],
)
op.create_index(
"ix_source_location_risk_bucket",
"source_location_snapshots",
["high_risk_count", "bucket_time"],
)
op.execute(
"""
UPDATE security_access_logs
SET category = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'PROBE'
WHEN status_code >= 500 THEN 'SERVER_ERROR'
WHEN auth_status = 'INVALID_TOKEN' THEN 'INVALID_TOKEN'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'ANONYMOUS_API'
WHEN status_code = 404 THEN 'NOT_FOUND_NOISE'
ELSE 'OTHER'
END,
severity = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'CRITICAL'
WHEN status_code >= 500 THEN 'HIGH'
WHEN auth_status = 'INVALID_TOKEN' THEN 'MEDIUM'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'MEDIUM'
ELSE 'LOW'
END
WHERE category = 'ABNORMAL_IP'
"""
)
def downgrade() -> None:
op.drop_index("ix_source_location_risk_bucket", table_name="source_location_snapshots")
op.drop_index("ix_source_location_country_bucket", table_name="source_location_snapshots")
op.drop_index("ix_source_location_bucket", table_name="source_location_snapshots")
op.drop_table("source_location_snapshots")
@@ -0,0 +1,30 @@
"""Remove deployment-specific coordinates from private source snapshots.
Revision ID: 20260710_03
Revises: 20260710_02
"""
from alembic import op
revision = "20260710_03"
down_revision = "20260710_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
UPDATE source_location_snapshots
SET longitude = NULL,
latitude = NULL
WHERE accuracy_level = 'private'
"""
)
def downgrade() -> None:
# The previous coordinates were a hardcoded deployment assumption and
# cannot be restored without reintroducing incorrect data.
pass
@@ -0,0 +1,40 @@
"""Add user login session activity records.
Revision ID: 20260710_04
Revises: 20260710_03
"""
import sqlalchemy as sa
from alembic import op
revision = "20260710_04"
down_revision = "20260710_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"user_login_sessions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("client_type", sa.String(length=16), nullable=False, server_default="web"),
sa.Column("client_platform", sa.String(length=32), nullable=True),
sa.Column("client_version", sa.String(length=64), nullable=True),
sa.Column("client_source", sa.String(length=32), nullable=True),
sa.Column("login_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("end_reason", sa.String(length=32), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_user_login_sessions_user_login", "user_login_sessions", ["user_id", "login_at"])
op.create_index("ix_user_login_sessions_last_seen", "user_login_sessions", ["last_seen_at"])
def downgrade() -> None:
op.drop_index("ix_user_login_sessions_last_seen", table_name="user_login_sessions")
op.drop_index("ix_user_login_sessions_user_login", table_name="user_login_sessions")
op.drop_table("user_login_sessions")
@@ -0,0 +1,25 @@
"""Add the server-observed login IP to user login sessions.
Revision ID: 20260713_01
Revises: 20260710_04
"""
import sqlalchemy as sa
from alembic import op
revision = "20260713_01"
down_revision = "20260710_04"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"user_login_sessions",
sa.Column("login_ip", sa.String(length=45), nullable=True),
)
def downgrade() -> None:
op.drop_column("user_login_sessions", "login_ip")
@@ -0,0 +1,25 @@
"""Store original filenames for document versions.
Revision ID: 20260713_02
Revises: 20260713_01
"""
import sqlalchemy as sa
from alembic import op
revision = "20260713_02"
down_revision = "20260713_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"document_versions",
sa.Column("original_filename", sa.String(length=255), nullable=True),
)
def downgrade() -> None:
op.drop_column("document_versions", "original_filename")
@@ -0,0 +1,136 @@
"""Add the independent shared-library collaboration module.
Revision ID: 20260714_01
Revises: 20260713_02
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260714_01"
down_revision = "20260713_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
uuid_type = postgresql.UUID(as_uuid=True)
op.create_table(
"collaboration_folders",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
sa.Column("parent_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_collaboration_folders_study_parent", "collaboration_folders", ["study_id", "parent_id"])
op.create_table(
"collaboration_files",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
sa.Column("folder_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("file_type", sa.String(16), nullable=False),
sa.Column("extension", sa.String(16), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
sa.Column("owner_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("current_revision_id", uuid_type),
sa.Column("generation", sa.Integer(), nullable=False, server_default="1"),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_collaboration_files_study_folder", "collaboration_files", ["study_id", "folder_id"])
op.create_index("ix_collaboration_files_study_status", "collaboration_files", ["study_id", "status"])
op.create_table(
"collaboration_members",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("role", sa.String(16), nullable=False),
sa.Column("invited_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"),
)
op.create_index("ix_collaboration_members_user", "collaboration_members", ["user_id"])
op.create_table(
"collaboration_revisions",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
sa.Column("revision_no", sa.Integer(), nullable=False),
sa.Column("parent_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
sa.Column("file_uri", sa.String(500), nullable=False),
sa.Column("original_filename", sa.String(255), nullable=False),
sa.Column("file_hash", sa.String(128), nullable=False),
sa.Column("file_size", sa.BigInteger(), nullable=False),
sa.Column("mime_type", sa.String(100), nullable=False),
sa.Column("source", sa.String(24), nullable=False),
sa.Column("change_summary", sa.Text()),
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"),
)
op.create_index("ix_collaboration_revisions_file_created", "collaboration_revisions", ["file_id", "created_at"])
op.create_foreign_key(
"fk_collaboration_files_current_revision",
"collaboration_files",
"collaboration_revisions",
["current_revision_id"],
["id"],
ondelete="SET NULL",
)
op.create_table(
"collaboration_sessions",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
sa.Column("base_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id"), nullable=False),
sa.Column("document_key", sa.String(128), nullable=False),
sa.Column("generation", sa.Integer(), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
sa.Column("started_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("active_users", sa.Text()),
sa.Column("last_callback_at", sa.DateTime(timezone=True)),
sa.Column("closed_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("document_key", name="uq_collaboration_session_document_key"),
)
op.create_index("ix_collaboration_sessions_file_status", "collaboration_sessions", ["file_id", "status"])
op.create_table(
"collaboration_callback_receipts",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("session_id", uuid_type, sa.ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False),
sa.Column("fingerprint", sa.String(128), nullable=False),
sa.Column("callback_status", sa.Integer(), nullable=False),
sa.Column("result", sa.String(24), nullable=False),
sa.Column("revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"),
)
def downgrade() -> None:
op.drop_table("collaboration_callback_receipts")
op.drop_index("ix_collaboration_sessions_file_status", table_name="collaboration_sessions")
op.drop_table("collaboration_sessions")
op.drop_constraint("fk_collaboration_files_current_revision", "collaboration_files", type_="foreignkey")
op.drop_index("ix_collaboration_revisions_file_created", table_name="collaboration_revisions")
op.drop_table("collaboration_revisions")
op.drop_index("ix_collaboration_members_user", table_name="collaboration_members")
op.drop_table("collaboration_members")
op.drop_index("ix_collaboration_files_study_status", table_name="collaboration_files")
op.drop_index("ix_collaboration_files_study_folder", table_name="collaboration_files")
op.drop_table("collaboration_files")
op.drop_index("ix_collaboration_folders_study_parent", table_name="collaboration_folders")
op.drop_table("collaboration_folders")
@@ -0,0 +1,53 @@
"""Add protected public links for collaboration files.
Revision ID: 20260715_01
Revises: 20260714_01
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260715_01"
down_revision = "20260714_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
uuid_type = postgresql.UUID(as_uuid=True)
op.create_table(
"collaboration_share_links",
sa.Column("id", uuid_type, primary_key=True),
sa.Column(
"file_id",
uuid_type,
sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("access_mode", sa.String(12), nullable=False, server_default="VIEW"),
sa.Column("expiry_policy", sa.String(16), nullable=False, server_default="SEVEN_DAYS"),
sa.Column("expires_at", sa.DateTime(timezone=True)),
sa.Column("password_hash", sa.String(255)),
sa.Column("token_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("failed_attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_failed_at", sa.DateTime(timezone=True)),
sa.Column("locked_until", sa.DateTime(timezone=True)),
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("updated_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("file_id", name="uq_collaboration_share_link_file"),
)
op.create_index(
"ix_collaboration_share_links_enabled_expiry",
"collaboration_share_links",
["enabled", "expires_at"],
)
def downgrade() -> None:
op.drop_index("ix_collaboration_share_links_enabled_expiry", table_name="collaboration_share_links")
op.drop_table("collaboration_share_links")
@@ -0,0 +1,25 @@
"""Add effective export controls to collaboration share links.
Revision ID: 20260715_02
Revises: 20260715_01
"""
import sqlalchemy as sa
from alembic import op
revision = "20260715_02"
down_revision = "20260715_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_share_links",
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
)
def downgrade() -> None:
op.drop_column("collaboration_share_links", "allow_export")
@@ -0,0 +1,43 @@
"""Add soft deletion metadata to collaboration revisions.
Revision ID: 20260715_03
Revises: 20260715_02
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260715_03"
down_revision = "20260715_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_revisions",
sa.Column("deleted_by", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"collaboration_revisions",
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_foreign_key(
"fk_collaboration_revision_deleted_by",
"collaboration_revisions",
"users",
["deleted_by"],
["id"],
)
def downgrade() -> None:
op.drop_constraint(
"fk_collaboration_revision_deleted_by",
"collaboration_revisions",
type_="foreignkey",
)
op.drop_column("collaboration_revisions", "deleted_at")
op.drop_column("collaboration_revisions", "deleted_by")
@@ -0,0 +1,35 @@
"""Move collaboration export control to the file.
Revision ID: 20260715_04
Revises: 20260715_03
"""
import sqlalchemy as sa
from alembic import op
revision = "20260715_04"
down_revision = "20260715_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_files",
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
)
# Preserve links that already granted export: after consolidation the same
# setting applies to authenticated collaborators and anonymous visitors.
op.execute(
"""
UPDATE collaboration_files AS file
SET allow_export = true
FROM collaboration_share_links AS link
WHERE link.file_id = file.id AND link.allow_export = true
"""
)
def downgrade() -> None:
op.drop_column("collaboration_files", "allow_export")
@@ -0,0 +1,33 @@
"""Remove the redundant share-link export permission.
Revision ID: 20260715_05
Revises: 20260715_04
"""
import sqlalchemy as sa
from alembic import op
revision = "20260715_05"
down_revision = "20260715_04"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("collaboration_share_links", "allow_export")
def downgrade() -> None:
op.add_column(
"collaboration_share_links",
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.execute(
"""
UPDATE collaboration_share_links AS link
SET allow_export = file.allow_export
FROM collaboration_files AS file
WHERE file.id = link.file_id
"""
)
@@ -0,0 +1,66 @@
"""Add collaboration edit requests and ownership controls.
Revision ID: 20260715_06
Revises: 20260715_05
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260715_06"
down_revision = "20260715_05"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_files",
sa.Column("allow_edit_request", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"collaboration_files",
sa.Column("allow_sheet_structure_edit", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"collaboration_files",
sa.Column("sheet_structure_protection_backup", sa.Text(), nullable=True),
)
op.create_table(
"collaboration_edit_requests",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("requester_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default="PENDING"),
sa.Column("resolved_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["file_id"], ["collaboration_files.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requester_id"], ["users.id"]),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_collaboration_edit_requests_file_status",
"collaboration_edit_requests",
["file_id", "status"],
)
op.create_index(
"uq_collaboration_edit_requests_pending_user",
"collaboration_edit_requests",
["file_id", "requester_id"],
unique=True,
postgresql_where=sa.text("status = 'PENDING'"),
)
def downgrade() -> None:
op.drop_index("uq_collaboration_edit_requests_pending_user", table_name="collaboration_edit_requests")
op.drop_index("ix_collaboration_edit_requests_file_status", table_name="collaboration_edit_requests")
op.drop_table("collaboration_edit_requests")
op.drop_column("collaboration_files", "sheet_structure_protection_backup")
op.drop_column("collaboration_files", "allow_sheet_structure_edit")
op.drop_column("collaboration_files", "allow_edit_request")
@@ -0,0 +1,53 @@
"""Add recipient-scoped generic notifications.
Revision ID: 20260716_01
Revises: 20260715_06
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260716_01"
down_revision = "20260715_06"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("recipient_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("category", sa.String(length=64), nullable=False),
sa.Column("priority", sa.String(length=16), nullable=False, server_default="NORMAL"),
sa.Column("title", sa.String(length=180), nullable=False),
sa.Column("message", sa.String(length=500), nullable=False),
sa.Column("action_path", sa.Text(), nullable=True),
sa.Column("source_type", sa.String(length=64), nullable=False),
sa.Column("source_id", sa.String(length=100), nullable=False),
sa.Column("source_version", sa.String(length=100), nullable=True),
sa.Column("dedupe_key", sa.String(length=255), nullable=False),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["recipient_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"),
)
op.create_index(
"ix_notifications_recipient_study_state",
"notifications",
["recipient_id", "study_id", "resolved_at", "read_at", "created_at"],
)
op.create_index("ix_notifications_source", "notifications", ["source_type", "source_id"])
def downgrade() -> None:
op.drop_index("ix_notifications_source", table_name="notifications")
op.drop_index("ix_notifications_recipient_study_state", table_name="notifications")
op.drop_table("notifications")
@@ -0,0 +1,75 @@
"""Backfill notifications for pending collaboration edit requests.
Revision ID: 20260716_02
Revises: 20260716_01
"""
from alembic import op
revision = "20260716_02"
down_revision = "20260716_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
INSERT INTO notifications (
id,
study_id,
recipient_id,
category,
priority,
title,
message,
action_path,
source_type,
source_id,
dedupe_key,
created_at,
updated_at
)
SELECT
gen_random_uuid(),
collaboration_files.study_id,
study_members.user_id,
'COLLABORATION_EDIT_REQUEST',
'NORMAL',
'新的编辑权限申请',
concat(
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
' 申请编辑“',
collaboration_files.title,
''
),
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
'COLLABORATION_EDIT_REQUEST',
collaboration_edit_requests.id::text,
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
collaboration_edit_requests.created_at,
collaboration_edit_requests.updated_at
FROM collaboration_edit_requests
JOIN collaboration_files
ON collaboration_files.id = collaboration_edit_requests.file_id
JOIN users
ON users.id = collaboration_edit_requests.requester_id
JOIN study_members
ON study_members.study_id = collaboration_files.study_id
AND study_members.is_active IS TRUE
LEFT JOIN collaboration_members
ON collaboration_members.file_id = collaboration_files.id
AND collaboration_members.user_id = study_members.user_id
WHERE collaboration_edit_requests.status = 'PENDING'
AND collaboration_files.deleted_at IS NULL
AND (
study_members.user_id = collaboration_files.owner_id
OR collaboration_members.role = 'MANAGER'
)
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
""")
def downgrade() -> None:
# 这是业务通知数据迁移;降级时保留已读/未读状态,避免删除升级后新产生的同源通知。
pass
@@ -0,0 +1,65 @@
"""Backfill pending edit request notifications for file owners.
Revision ID: 20260716_03
Revises: 20260716_02
"""
from alembic import op
revision = "20260716_03"
down_revision = "20260716_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
INSERT INTO notifications (
id,
study_id,
recipient_id,
category,
priority,
title,
message,
action_path,
source_type,
source_id,
dedupe_key,
created_at,
updated_at
)
SELECT
gen_random_uuid(),
collaboration_files.study_id,
collaboration_files.owner_id,
'COLLABORATION_EDIT_REQUEST',
'NORMAL',
'新的编辑权限申请',
concat(
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
' 申请编辑“',
collaboration_files.title,
''
),
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
'COLLABORATION_EDIT_REQUEST',
collaboration_edit_requests.id::text,
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
collaboration_edit_requests.created_at,
collaboration_edit_requests.updated_at
FROM collaboration_edit_requests
JOIN collaboration_files
ON collaboration_files.id = collaboration_edit_requests.file_id
JOIN users
ON users.id = collaboration_edit_requests.requester_id
WHERE collaboration_edit_requests.status = 'PENDING'
AND collaboration_files.deleted_at IS NULL
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
""")
def downgrade() -> None:
# 与上一数据迁移一致,降级时保留已产生的业务通知状态。
pass
@@ -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")
@@ -0,0 +1,76 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_roles
from app.schemas.email_settings import (
EmailDomainCreateRequest,
EmailSettingsListResponse,
EmailSettingsRead,
EmailSettingsUpdate,
EmailTestRequest,
)
from app.services import email_service
router = APIRouter(prefix="/email-settings")
@router.get("/", response_model=EmailSettingsListResponse)
async def read_email_settings(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsListResponse:
items = [email_service.to_email_settings_read(row) for row in await email_service.list_email_settings(db)]
return EmailSettingsListResponse(items=items, total=len(items))
@router.post("/", response_model=EmailSettingsRead)
async def create_email_domain(
payload: EmailDomainCreateRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsRead:
settings_row = await email_service.create_email_domain(db, payload.register_domain, updated_by=current_user.id)
return email_service.to_email_settings_read(settings_row)
@router.get("/{register_domain}", response_model=EmailSettingsRead)
async def read_email_domain_settings(
register_domain: str,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsRead:
return email_service.to_email_settings_read(await email_service.get_email_settings(db, register_domain))
@router.put("/{register_domain}", response_model=EmailSettingsRead)
async def update_email_settings(
register_domain: str,
payload: EmailSettingsUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsRead:
settings_row = await email_service.upsert_email_settings(db, register_domain, payload, updated_by=current_user.id)
return email_service.to_email_settings_read(settings_row)
@router.delete("/{register_domain}")
async def delete_email_domain(
register_domain: str,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
):
await email_service.delete_email_domain(db, register_domain)
return {"message": "邮箱后缀已删除"}
@router.post("/{register_domain}/test")
async def test_email_settings(
register_domain: str,
payload: EmailTestRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
):
await email_service.send_test_email(db, register_domain, str(payload.recipient_email))
return {"message": "测试邮件已发送"}
-62
View File
@@ -1,62 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, is_system_admin, require_roles
from app.crud import user as user_crud
from app.models.user import User, UserStatus
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
router = APIRouter(prefix="/users")
@router.get("/", response_model=AdminUserListResponse)
async def list_users_for_review(
status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> AdminUserListResponse:
users = await user_crud.list_users_by_status(db, status=status_filter)
return AdminUserListResponse(items=list(users), total=len(users))
async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User:
user = await user_crud.get_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if is_system_admin(user):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
return user
@router.post("/{user_id}/approve", response_model=UserResponse)
async def approve_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> UserResponse:
if review.action != "approve":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
user = await _get_review_user(db, user_id)
if user.status != UserStatus.PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
user = await user_crud.approve_user(db, user, admin_id=current_user.id)
return user
@router.post("/{user_id}/reject", response_model=UserResponse)
async def reject_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> UserResponse:
if review.action not in ("reject", "approve"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
user = await _get_review_user(db, user_id)
if user.status != UserStatus.PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
user = await user_crud.reject_user(db, user, admin_id=current_user.id)
return user
+35 -34
View File
@@ -77,6 +77,7 @@ FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
"read": "faq:read", "read": "faq:read",
"delete": "faq_attachments:delete", "delete": "faq_attachments:delete",
} }
SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = { STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
"startup_kickoff", "startup_kickoff",
"startup_kickoff_minutes", "startup_kickoff_minutes",
@@ -239,16 +240,17 @@ async def upload_attachment(
content_type=file.content_type, content_type=file.content_type,
uploaded_by=current_user.id, uploaded_by=current_user.id,
) )
await audit_crud.log_action( if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
db, await audit_crud.log_action(
study_id=study_id, db,
entity_type=entity_type, study_id=study_id,
entity_id=entity_id, entity_type=entity_type,
action="UPLOAD_FILE", entity_id=entity_id,
detail=f"文件已上传:{file.filename}", action="UPLOAD_FILE",
operator_id=current_user.id, detail=f"文件已上传:{file.filename}",
operator_role=await get_operator_role_label(db, study_id, current_user), operator_id=current_user.id,
) operator_role=await get_operator_role_label(db, study_id, current_user),
)
return AttachmentRead( return AttachmentRead(
id=attachment.id, id=attachment.id,
filename=attachment.filename, filename=attachment.filename,
@@ -346,13 +348,10 @@ async def preview_attachment(
async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID): async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID):
token = None
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header and auth_header.lower().startswith("bearer "): if auth_header and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1] token = auth_header.split(" ", 1)[1]
if not token: else:
token = request.query_params.get("token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
payload = decode_token(token) payload = decode_token(token)
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub")))) user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
@@ -445,16 +444,17 @@ async def global_delete_attachment(
if not can_delete: if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment) await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action( if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
db, await audit_crud.log_action(
study_id=attachment.study_id, db,
entity_type=attachment.entity_type, study_id=attachment.study_id,
entity_id=attachment.entity_id, entity_type=attachment.entity_type,
action="DELETE_ATTACHMENT", entity_id=attachment.entity_id,
detail=f"文件已删除:{attachment.filename}", action="DELETE_ATTACHMENT",
operator_id=user.id, detail=f"文件已删除:{attachment.filename}",
operator_role=await get_operator_role_label(db, attachment.study_id, user), operator_id=user.id,
) operator_role=await get_operator_role_label(db, attachment.study_id, user),
)
@router.delete( @router.delete(
@@ -497,13 +497,14 @@ async def delete_attachment(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment) await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action( if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
db, await audit_crud.log_action(
study_id=study_id, db,
entity_type=entity_type, study_id=study_id,
entity_id=entity_id, entity_type=entity_type,
action="DELETE_ATTACHMENT", entity_id=entity_id,
detail=f"文件已删除:{attachment.filename}", action="DELETE_ATTACHMENT",
operator_id=current_user.id, detail=f"文件已删除:{attachment.filename}",
operator_role=await get_operator_role_label(db, study_id, current_user), operator_id=current_user.id,
) operator_role=await get_operator_role_label(db, study_id, current_user),
)
+51 -1
View File
@@ -1,13 +1,18 @@
import json import json
import uuid import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.models.user import User
from app.schemas.audit import AuditEventCreate, AuditLogRead from app.schemas.audit import AuditEventCreate, AuditLogRead
from app.services.ip_location import resolve_ip_location
router = APIRouter() router = APIRouter()
@@ -40,6 +45,10 @@ async def list_audit_logs(
entity_id: uuid.UUID | None = None, entity_id: uuid.UUID | None = None,
action: str | None = None, action: str | None = None,
operator_id: uuid.UUID | None = None, operator_id: uuid.UUID | None = None,
client_ip: str | None = None,
client_type: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
@@ -51,10 +60,51 @@ async def list_audit_logs(
entity_id=entity_id, entity_id=entity_id,
action=action, action=action,
operator_id=operator_id, operator_id=operator_id,
client_ip=client_ip,
client_type=client_type,
start_time=start_time,
end_time=end_time,
skip=skip, skip=skip,
limit=limit, limit=limit,
) )
return list(logs) operator_ids = {log.operator_id for log in logs}
operator_map: dict[uuid.UUID, User] = {}
if operator_ids:
result = await db.execute(select(User).where(User.id.in_(operator_ids)))
operator_map = {user.id: user for user in result.scalars().all()}
items: list[AuditLogRead] = []
for log in logs:
ip_location = resolve_ip_location(log.client_ip)
operator = operator_map.get(log.operator_id)
items.append(
AuditLogRead(
id=log.id,
study_id=log.study_id,
entity_type=log.entity_type,
entity_id=log.entity_id,
action=log.action,
detail=log.detail,
operator_id=log.operator_id,
operator_name=operator.full_name if operator else None,
operator_email=operator.email if operator else None,
operator_role=log.operator_role,
client_ip=log.client_ip,
ip_location=ip_location.location,
ip_country=ip_location.country,
ip_province=ip_location.province,
ip_city=ip_location.city,
ip_isp=ip_location.isp,
user_agent=log.user_agent,
client_type=log.client_type,
client_version=log.client_version,
client_platform=log.client_platform,
build_channel=log.build_channel,
build_commit=log.build_commit,
created_at=log.created_at,
)
)
return items
@router.post( @router.post(
+218 -15
View File
@@ -1,18 +1,38 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from dataclasses import dataclass
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi import File, UploadFile from fastapi import File, UploadFile
from pydantic import BaseModel, Field from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path from pathlib import Path
import uuid import uuid
from app.core.config import settings from app.core.config import settings
from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password from app.core.request_context import resolve_client_ip, resolve_ctms_client_type
from app.core.security import create_access_token, decode_token, decode_token_allow_expired, oauth2_scheme, verify_password
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud from app.crud import user as user_crud
from app.models.user import UserStatus from app.models.user import UserStatus
from app.schemas.email_settings import (
EmailCodeResponse,
EmailCodeVerifyResponse,
PasswordResetCodeVerifyRequest,
PasswordResetCodeVerifyResponse,
PasswordResetLinkSendRequest,
PasswordResetRequest,
PasswordResetTokenRequest,
RegisterEmailCodeSendRequest,
RegisterEmailCodeVerifyRequest,
)
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
from app.services import email_service
from app.services.user_login_sessions import (
create_login_session,
end_login_session,
session_id_from_payload,
touch_login_session,
)
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -39,6 +59,14 @@ class ExtendResponse(BaseModel):
expiresAt: datetime expiresAt: datetime
class EmailAvailabilityResponse(BaseModel):
available: bool
class EmailDomainsResponse(BaseModel):
items: list[str]
router = APIRouter() router = APIRouter()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars" AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True) AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
@@ -50,12 +78,59 @@ AVATAR_ALLOWED_CONTENT_TYPES = {
} }
def issue_user_token(db_user) -> Token: @dataclass(frozen=True)
class SessionPolicy:
access_minutes: int
absolute_max_seconds: int
def normalize_session_client_type(value: str | None) -> str:
return "desktop" if (value or "").strip().lower() == "desktop" else "web"
def get_session_policy_for_client_type(client_type: str) -> SessionPolicy:
if client_type == "desktop":
max_seconds = settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
return SessionPolicy(
access_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
absolute_max_seconds=max_seconds,
)
return SessionPolicy(
access_minutes=settings.JWT_EXPIRE_MINUTES,
absolute_max_seconds=settings.ABSOLUTE_SESSION_MAX_HOURS * 3600,
)
def get_request_session_client_type(request: Request) -> str:
return normalize_session_client_type(resolve_ctms_client_type(request.headers))
def policy_expires_at(issued_at: datetime, session_start: datetime, policy: SessionPolicy) -> datetime:
access_expires_at = issued_at + timedelta(minutes=policy.access_minutes)
session_expires_at = session_start + timedelta(seconds=policy.absolute_max_seconds)
return min(access_expires_at, session_expires_at)
async def issue_user_token(db_user, request: Request, db: AsyncSession) -> Token:
session_start = datetime.now(timezone.utc) session_start = datetime.now(timezone.utc)
session_id = uuid.uuid4()
client_type = get_request_session_client_type(request)
policy = get_session_policy_for_client_type(client_type)
access_token = create_access_token( access_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
expires_minutes=None, expires_minutes=policy.access_minutes,
session_start=session_start, session_start=session_start,
max_age_seconds=policy.absolute_max_seconds,
issued_at=session_start,
client_type=client_type,
session_id=str(session_id),
)
await create_login_session(
db,
session_id=session_id,
user_id=db_user.id,
request=request,
login_at=session_start,
) )
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token, token_type="bearer")
@@ -108,6 +183,92 @@ def ensure_user_active(db_user) -> None:
) )
@router.get("/email-domains", response_model=EmailDomainsResponse)
async def read_email_domains(
response: Response,
db: AsyncSession = Depends(get_db_session),
) -> EmailDomainsResponse:
response.headers["Cache-Control"] = "no-store"
rows = await email_service.list_email_settings(db)
return EmailDomainsResponse(items=[row.register_domain for row in rows])
@router.get("/register/email-availability", response_model=EmailAvailabilityResponse)
async def check_register_email_availability(
email: EmailStr = Query(...),
db: AsyncSession = Depends(get_db_session),
) -> EmailAvailabilityResponse:
existing = await user_crud.get_by_email(db, str(email))
return EmailAvailabilityResponse(available=existing is None)
@router.post("/register/email-code/send", response_model=EmailCodeResponse)
async def send_register_email_code(
payload: RegisterEmailCodeSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_register_code(db, str(payload.email))
return EmailCodeResponse(message="验证码已发送")
@router.post("/register/email-code/verify", response_model=EmailCodeVerifyResponse)
async def verify_register_email_code(
payload: RegisterEmailCodeVerifyRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeVerifyResponse:
await email_service.verify_register_code(db, str(payload.email), payload.code)
return EmailCodeVerifyResponse(verified=True)
@router.post("/password-reset/email-code/send", response_model=EmailCodeResponse)
async def send_password_reset_email_code(
payload: PasswordResetLinkSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_password_reset_code(db, str(payload.email))
return EmailCodeResponse(message="验证码发送成功,请查收邮箱")
@router.post("/password-reset/email-code/verify", response_model=PasswordResetCodeVerifyResponse)
async def verify_password_reset_email_code(
payload: PasswordResetCodeVerifyRequest,
db: AsyncSession = Depends(get_db_session),
) -> PasswordResetCodeVerifyResponse:
reset_token = await email_service.verify_password_reset_code(db, str(payload.email), payload.code)
return PasswordResetCodeVerifyResponse(verified=True, reset_token=reset_token)
@router.post("/password-reset-link/send", response_model=EmailCodeResponse)
async def send_password_reset_link(
payload: PasswordResetLinkSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_password_reset_link(
db,
str(payload.email),
frontend_origin=settings.FRONTEND_PUBLIC_URL,
)
return EmailCodeResponse(message="如果账号存在,重置链接已发送")
@router.post("/password-reset", response_model=EmailCodeResponse)
async def reset_password(
payload: PasswordResetRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.reset_password_with_code(db, str(payload.email), payload.code, payload.password)
return EmailCodeResponse(message="密码已重置,请返回登录")
@router.post("/password-reset-link", response_model=EmailCodeResponse)
async def reset_password_with_link(
payload: PasswordResetTokenRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.reset_password_with_token(db, payload.token, payload.password)
return EmailCodeResponse(message="密码已重置,请返回登录")
@router.post("/register", status_code=status.HTTP_201_CREATED) @router.post("/register", status_code=status.HTTP_201_CREATED)
async def register( async def register(
payload: UserRegisterRequest, payload: UserRegisterRequest,
@@ -116,8 +277,9 @@ async def register(
existing = await user_crud.get_by_email(db, payload.email) existing = await user_crud.get_by_email(db, payload.email)
if existing: if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await user_crud.create_pending_user(db, payload) await email_service.ensure_register_email_verified(db, str(payload.email))
return {"message": "注册成功,等待管理员审核"} await user_crud.create_registered_user(db, payload)
return {"message": "注册成功,请登录"}
@router.get("/login-key", response_model=LoginKeyResponse) @router.get("/login-key", response_model=LoginKeyResponse)
@@ -133,23 +295,23 @@ async def get_login_key() -> LoginKeyResponse:
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login_for_access_token( async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session) payload: LoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
) -> Token: ) -> Token:
db_user = await authenticate_encrypted_password(payload, db) db_user = await authenticate_encrypted_password(payload, db)
ensure_user_active(db_user) ensure_user_active(db_user)
return issue_user_token(db_user) return await issue_user_token(db_user, request, db)
@router.post("/dev-login", response_model=Token) @router.post("/dev-login", response_model=Token)
async def dev_login_for_access_token( async def dev_login_for_access_token(
payload: DevLoginRequest, db: AsyncSession = Depends(get_db_session) payload: DevLoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
) -> Token: ) -> Token:
if settings.ENV != "development": if settings.ENV != "development":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
db_user = await authenticate_plain_password(payload, db) db_user = await authenticate_plain_password(payload, db)
ensure_user_active(db_user) ensure_user_active(db_user)
return issue_user_token(db_user) return await issue_user_token(db_user, request, db)
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
@@ -178,22 +340,63 @@ async def extend_access_token(
if db_user.status != UserStatus.ACTIVE: if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start_ts = payload.get("orig_iat") or payload.get("iat") session_start_ts = payload.get("orig_iat") or payload.get("iat")
policy = get_session_policy_for_client_type(normalize_session_client_type(payload.get("client_type")))
if session_start_ts: if session_start_ts:
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600 if now_ts - int(session_start_ts) > policy.absolute_max_seconds:
if now_ts - int(session_start_ts) > max_seconds:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc) session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
else: else:
session_start = datetime.now(timezone.utc) session_start = datetime.now(timezone.utc)
issued_at = datetime.now(timezone.utc)
new_token = create_access_token( new_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
expires_minutes=None, expires_minutes=policy.access_minutes,
session_start=session_start, session_start=session_start,
max_age_seconds=policy.absolute_max_seconds,
issued_at=issued_at,
client_type=normalize_session_client_type(payload.get("client_type")),
session_id=str(session_id_from_payload(payload)),
) )
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) expires_at = policy_expires_at(issued_at, session_start, policy)
return ExtendResponse(accessToken=new_token, expiresAt=expires_at) return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
@router.post("/session/heartbeat")
async def heartbeat_login_session(
request: Request,
token: str = Depends(oauth2_scheme),
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
session = await touch_login_session(
db,
user_id=current_user.id,
payload=decode_token(token),
request=request,
)
if session is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录会话已结束")
return {
"status": "online",
"last_seen_at": session.last_seen_at.isoformat(),
"client_ip": resolve_client_ip(request),
}
@router.post("/session/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout_login_session(
token: str = Depends(oauth2_scheme),
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> Response:
await end_login_session(
db,
user_id=current_user.id,
payload=decode_token(token),
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.patch("/me", response_model=UserRead) @router.patch("/me", response_model=UserRead)
async def update_me( async def update_me(
payload: UserSelfUpdate, payload: UserSelfUpdate,
+629
View File
@@ -0,0 +1,629 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, File, Form, Header, Request, Response, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_api_permission
from app.schemas.collaboration import (
CollaborationCallbackPayload,
CollaborationCandidateRead,
CollaborationEditorConfigRead,
CollaborationEditRequestRead,
CollaborationEditRequestResolve,
CollaborationExportRecord,
CollaborationFileCreate,
CollaborationFileRead,
CollaborationFileUpdate,
CollaborationFolderCreate,
CollaborationFolderRead,
CollaborationFolderUpdate,
CollaborationMemberRead,
CollaborationMemberUpsert,
CollaborationOwnershipTransferRequest,
CollaborationPublicEditorConfigRequest,
CollaborationPublicShareMetadata,
CollaborationRestoreRequest,
CollaborationRevisionCopyRequest,
CollaborationRevisionRead,
CollaborationRevisionUpdate,
CollaborationShareAccessGrant,
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
CollaborationSharePasswordRequest,
)
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import collaboration_service, collaboration_share_service, onlyoffice_collaboration_service, onlyoffice_service
router = APIRouter()
public_router = APIRouter()
internal_router = APIRouter(include_in_schema=False)
@router.get(
"/folders",
response_model=list[CollaborationFolderRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_folders(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
return await collaboration_service.list_folders(db, study_id)
@router.post(
"/folders",
response_model=CollaborationFolderRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def create_folder(
study_id: uuid.UUID,
payload: CollaborationFolderCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
return await collaboration_service.create_folder(db, study_id, payload, current_user)
@router.patch(
"/folders/{folder_id}",
response_model=CollaborationFolderRead,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def update_folder(
study_id: uuid.UUID,
folder_id: uuid.UUID,
payload: CollaborationFolderUpdate,
db: AsyncSession = Depends(get_db_session),
):
return await collaboration_service.update_folder(db, study_id, folder_id, payload)
@router.delete(
"/folders/{folder_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def delete_folder(study_id: uuid.UUID, folder_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
await collaboration_service.delete_folder(db, study_id, folder_id)
@router.get(
"/files",
response_model=list[CollaborationFileRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_files(
study_id: uuid.UUID,
folder_id: uuid.UUID | None = None,
keyword: str | None = None,
deleted: bool = False,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
return await collaboration_service.list_files(
db, study_id, current_user, folder_id=folder_id, keyword=keyword, deleted=deleted
)
@router.post(
"/files",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def create_file(
study_id: uuid.UUID,
payload: CollaborationFileCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.create_blank_file(db, study_id, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.post(
"/files/import",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def import_file(
study_id: uuid.UUID,
file: UploadFile = File(...),
folder_id: uuid.UUID | None = Form(None),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.import_file(db, study_id, folder_id, file, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.post(
"/files/{file_id}/copy",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def copy_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
source = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.copy_file(db, source, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/download",
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def download_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
revision = await collaboration_service.prepare_download(db, item, current_user)
return FileResponse(
path=revision.file_uri,
media_type=revision.mime_type,
filename=item.title,
content_disposition_type="attachment",
headers={"Cache-Control": "no-store"},
)
@router.patch(
"/files/{file_id}",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationFileUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.update_file(db, item, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.delete(
"/files/{file_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def trash_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.move_to_trash(db, item, current_user)
@router.post(
"/files/{file_id}/restore",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def restore_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id, include_deleted=True)
item = await collaboration_service.restore_file(db, item, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/members",
response_model=list[CollaborationMemberRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_members(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_members(db, item)
@router.put(
"/files/{file_id}/members",
response_model=CollaborationMemberRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def upsert_member(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationMemberUpsert,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.upsert_member(db, item, payload, current_user)
@router.delete(
"/files/{file_id}/members/{user_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def remove_member(
study_id: uuid.UUID,
file_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.remove_member(db, item, user_id, current_user)
@router.post(
"/files/{file_id}/edit-requests",
response_model=CollaborationEditRequestRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def create_edit_request(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.create_edit_request(db, item, current_user)
@router.get(
"/files/{file_id}/edit-requests",
response_model=list[CollaborationEditRequestRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_edit_requests(
study_id: uuid.UUID,
file_id: uuid.UUID,
pending_only: bool = True,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_edit_requests(db, item, current_user, pending_only=pending_only)
@router.post(
"/files/{file_id}/edit-requests/{request_id}/resolve",
response_model=CollaborationEditRequestRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def resolve_edit_request(
study_id: uuid.UUID,
file_id: uuid.UUID,
request_id: uuid.UUID,
payload: CollaborationEditRequestResolve,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, current_user)
@router.post(
"/files/{file_id}/transfer-ownership",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def transfer_ownership(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationOwnershipTransferRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.transfer_ownership(db, item, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/share-link",
response_model=CollaborationShareLinkRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_share_link(
study_id: uuid.UUID,
file_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await collaboration_share_service.get_share_link(db, item, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.put(
"/files/{file_id}/share-link",
response_model=CollaborationShareLinkRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_share_link(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationShareLinkUpdate,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await collaboration_share_service.update_share_link(db, item, payload, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.get(
"/member-candidates",
response_model=list[CollaborationCandidateRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_member_candidates(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
return await collaboration_service.list_candidates(db, study_id)
@router.get(
"/files/{file_id}/revisions",
response_model=list[CollaborationRevisionRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_revisions(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_revisions(db, item)
@router.patch(
"/files/{file_id}/revisions/{revision_id}",
response_model=CollaborationRevisionRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRevisionUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.update_revision(db, item, revision_id, payload, current_user)
@router.delete(
"/files/{file_id}/revisions/{revision_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def delete_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.delete_revision(db, item, revision_id, current_user)
@router.post(
"/files/{file_id}/revisions/{revision_id}/copy",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[
Depends(require_api_permission("collaboration:create")),
Depends(require_api_permission("collaboration:read")),
],
)
async def copy_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRevisionCopyRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
copied = await collaboration_service.copy_revision(db, item, revision_id, payload, current_user)
return await collaboration_service.file_read(db, copied, current_user)
@router.get(
"/files/{file_id}/revisions/{revision_id}/preview-config",
response_model=OnlyOfficePreviewConfigRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_revision_preview_config(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, current_user)
await onlyoffice_service.ensure_onlyoffice_available()
result = onlyoffice_service.build_preview_config(
resource_type="collaboration_revision",
resource_id=revision.id,
file_name=item.title,
file_hash=revision.file_hash,
user_id=current_user.id,
user_name=current_user.full_name,
)
response.headers["Cache-Control"] = "no-store"
return result
@router.post(
"/files/{file_id}/revisions/{revision_id}/restore",
response_model=CollaborationRevisionRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def restore_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRestoreRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.restore_revision(
db, item, revision_id, current_user, payload.change_summary
)
@router.get(
"/files/{file_id}/editor-config",
response_model=CollaborationEditorConfigRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_editor_config(
study_id: uuid.UUID,
file_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await onlyoffice_collaboration_service.build_editor_config(db, item, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.post(
"/files/{file_id}/exports",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def record_export(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.record_export(db, item, current_user, payload.file_type)
@router.post(
"/files/{file_id}/downloads",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def record_download(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.record_download(db, item, current_user, payload.file_type)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@public_router.get("/metadata", response_model=CollaborationPublicShareMetadata)
async def get_public_share_metadata(
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
result = await collaboration_share_service.public_metadata(db, x_ctms_share_token)
response.headers["Cache-Control"] = "no-store"
return result
@public_router.post("/access", response_model=CollaborationShareAccessGrant)
async def verify_public_share_password(
payload: CollaborationSharePasswordRequest,
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
result = await collaboration_share_service.verify_share_password(
db, x_ctms_share_token, payload.password
)
response.headers["Cache-Control"] = "no-store"
return result
@public_router.post("/editor-config", response_model=CollaborationEditorConfigRead)
async def get_public_share_editor_config(
payload: CollaborationPublicEditorConfigRequest,
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
link, item = await collaboration_share_service.resolve_active_share(db, x_ctms_share_token)
collaboration_share_service.validate_access_grant(link, payload.access_token)
result = await onlyoffice_collaboration_service.build_shared_editor_config(
db,
item,
link,
client_id=payload.client_id,
display_name=payload.display_name,
)
response.headers["Cache-Control"] = "no-store"
return result
@internal_router.get("/internal/onlyoffice/collaboration/sessions/{session_id}/content")
async def get_session_content(session_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session)):
revision, item = await onlyoffice_collaboration_service.get_session_content(
db, session_id, request.headers.get("AuthorizationJwt")
)
return FileResponse(
path=revision.file_uri,
media_type=revision.mime_type,
filename=item.title,
content_disposition_type="inline",
)
@internal_router.post("/internal/onlyoffice/collaboration/sessions/{session_id}/callback")
async def collaboration_callback(
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
onlyoffice_collaboration_service.validate_callback_token(
request.headers.get("AuthorizationJwt"), payload
)
return await onlyoffice_collaboration_service.process_callback(db, session_id, payload)
@@ -0,0 +1,86 @@
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
from app.schemas.notification import (
DesktopNotificationAckRequest,
DesktopNotificationClaimRequest,
DesktopNotificationClaimResponse,
DesktopNotificationSubscriptionRead,
DesktopNotificationSubscriptionUpdate,
)
from app.services import desktop_notification_service
router = APIRouter()
@router.get("/subscription", response_model=DesktopNotificationSubscriptionRead)
async def read_subscription(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DesktopNotificationSubscriptionRead:
subscription = await desktop_notification_service.get_subscription(db, current_user.id)
return DesktopNotificationSubscriptionRead(
enabled=bool(subscription and subscription.enabled),
enabled_at=subscription.enabled_at if subscription else None,
)
@router.put("/subscription", response_model=DesktopNotificationSubscriptionRead)
async def update_subscription(
payload: DesktopNotificationSubscriptionUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DesktopNotificationSubscriptionRead:
subscription = await desktop_notification_service.set_subscription(
db, current_user.id, payload.enabled
)
return DesktopNotificationSubscriptionRead(
enabled=subscription.enabled,
enabled_at=subscription.enabled_at,
)
@router.post("/claim", response_model=DesktopNotificationClaimResponse)
async def claim_notifications(
payload: DesktopNotificationClaimRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DesktopNotificationClaimResponse:
if payload.limit < 1 or payload.limit > 50:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="limit 必须在 1 到 50 之间")
claim_token, lease_expires_at, items = await desktop_notification_service.claim_notifications(
db, current_user.id, payload.limit
)
return DesktopNotificationClaimResponse(
claim_token=claim_token,
lease_expires_at=lease_expires_at,
items=items,
)
@router.post("/ack", status_code=status.HTTP_204_NO_CONTENT)
async def acknowledge_notifications(
payload: DesktopNotificationAckRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await desktop_notification_service.acknowledge_notifications(
db,
current_user.id,
payload.claim_token,
payload.delivered_ids,
)
@router.post("/{notification_id}/read", status_code=status.HTTP_204_NO_CONTENT)
async def mark_notification_read(
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, notification_id
)
+1 -36
View File
@@ -1,15 +1,11 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession 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.deps import get_current_user, get_db_session, 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.crud import faq_category as category_crud from app.crud import faq_category as category_crud
from app.crud import faq_item as item_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.common import PaginatedResponse
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
from app.utils.pagination import paginate from app.utils.pagination import paginate
@@ -47,16 +43,6 @@ async def create_category(
if dup: if dup:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
category = await category_crud.create_category(db, payload) 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) return CategoryRead.model_validate(category)
@@ -112,16 +98,6 @@ async def update_category(
if not target_study_id: if not target_study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
updated = await category_crud.update_category(db, category, payload) 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) 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) item_count = await item_crud.count_items_by_category(db, category_id)
if item_count > 0: if item_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
category_name = category.name
await db.delete(category) await db.delete(category)
await db.commit() 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 import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession 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.deps import get_current_user, get_db_session, 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.crud import faq_category as category_crud from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud from app.crud import faq_item as faq_crud
from app.crud import faq_reply as reply_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) 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( @router.post(
"/", "/",
response_model=FaqRead, response_model=FaqRead,
@@ -74,17 +64,6 @@ async def create_faq(
reply_in=FaqReplyCreate(content=payload.answer), reply_in=FaqReplyCreate(content=payload.answer),
) )
await faq_crud.set_status(db, item.id, "PROCESSING") 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) return FaqRead.model_validate(item)
@@ -178,19 +157,6 @@ async def update_faq(
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
updated = await faq_crud.update_item(db, item, payload) 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) return FaqRead.model_validate(updated)
@@ -337,17 +303,6 @@ async def create_reply(
if item.status != "RESOLVED": if item.status != "RESOLVED":
await faq_crud.set_status(db, item.id, "PROCESSING") await faq_crud.set_status(db, item.id, "PROCESSING")
await faq_crud.touch_item(db, item.id) 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) data = FaqReplyRead.model_validate(reply)
if quote: if quote:
if quote.is_deleted: if quote.is_deleted:
@@ -380,20 +335,9 @@ async def delete_faq(
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") 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 reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item) await db.delete(item)
await db.commit() 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( @router.delete(
@@ -415,7 +359,6 @@ async def delete_reply(
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") 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) reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id: if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
@@ -438,13 +381,3 @@ async def delete_reply(
resolved_by_confirm=False, resolved_by_confirm=False,
) )
await faq_crud.touch_item(db, item.id) 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),
)
+65 -3
View File
@@ -1,11 +1,11 @@
import uuid import uuid
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, get_current_user, require_study_member from app.core.deps import get_db_session, get_current_user, require_study_member
from app.schemas.notification import NotificationItem from app.schemas.notification import GeneralNotificationFeed, GeneralNotificationRead, NotificationItem
from app.services import document_service from app.services import document_service, notification_service, project_reminder_service
router = APIRouter() router = APIRouter()
@@ -29,3 +29,65 @@ async def list_notifications(
skip=skip, skip=skip,
limit=limit, limit=limit,
) )
@router.get(
"/notifications/feed",
response_model=GeneralNotificationFeed,
dependencies=[Depends(require_study_member())],
)
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:
# 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,
)
@router.post(
"/notifications/{notification_id}/read",
response_model=GeneralNotificationRead,
dependencies=[Depends(require_study_member())],
)
async def mark_general_notification_read(
study_id: uuid.UUID,
notification_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> GeneralNotificationRead:
return await notification_service.mark_read(
db,
study_id=study_id,
recipient_id=current_user.id,
notification_id=notification_id,
)
@router.post(
"/notifications/read-all",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member())],
)
async def mark_all_general_notifications_read(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> Response:
await notification_service.mark_all_read(db, study_id=study_id, recipient_id=current_user.id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import os
import uuid
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, Request, Response, status
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
from app.crud import attachment as attachment_crud
from app.crud import document as document_crud
from app.crud import document_version as version_crud
from app.models.collaboration import CollaborationRevision
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import document_service, onlyoffice_service
router = APIRouter()
internal_router = APIRouter(include_in_schema=False)
def _content_disposition(filename: str) -> str:
fallback = "".join(
character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_"
for character in filename
) or "document"
encoded = quote(filename, safe="")
return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
@router.get(
"/attachments/{attachment_id}/config",
response_model=OnlyOfficePreviewConfigRead,
)
async def get_attachment_preview_config(
attachment_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> OnlyOfficePreviewConfigRead:
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise onlyoffice_service.onlyoffice_error(
"ATTACHMENT_NOT_FOUND", "附件不存在", status.HTTP_404_NOT_FOUND
)
await _ensure_study_exists(db, attachment.study_id)
await _ensure_attachment_permission(
db,
attachment.study_id,
attachment.entity_type,
attachment.entity_id,
"read",
current_user,
)
if not os.path.exists(attachment.file_path):
raise onlyoffice_service.onlyoffice_error(
"ATTACHMENT_FILE_NOT_FOUND", "服务器未找到文件", status.HTTP_404_NOT_FOUND
)
if not onlyoffice_service.office_format_for_filename(attachment.filename):
raise onlyoffice_service.onlyoffice_error(
"ONLYOFFICE_FORMAT_UNSUPPORTED",
"该文件格式不支持 Office 在线预览",
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
)
await onlyoffice_service.ensure_onlyoffice_available()
result = onlyoffice_service.build_preview_config(
resource_type="attachment",
resource_id=attachment.id,
file_name=attachment.filename,
user_id=current_user.id,
user_name=current_user.full_name,
)
response.headers["Cache-Control"] = "no-store"
return result
@router.get(
"/versions/{version_id}/config",
response_model=OnlyOfficePreviewConfigRead,
)
async def get_version_preview_config(
version_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> OnlyOfficePreviewConfigRead:
version = await version_crud.get(db, version_id)
if not version:
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND
)
document = await document_crud.get(db, version.document_id)
if not document:
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_NOT_FOUND", "文档不存在", status.HTTP_404_NOT_FOUND
)
await document_service._ensure_study_access(db, document.trial_id, current_user, action="view")
file_path = Path(version.file_uri)
if not file_path.exists():
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
)
file_name = version.original_filename or document_service._legacy_download_filename(version, document)
if not onlyoffice_service.office_format_for_filename(file_name):
raise onlyoffice_service.onlyoffice_error(
"ONLYOFFICE_FORMAT_UNSUPPORTED",
"该文件格式不支持 Office 在线预览",
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
)
await onlyoffice_service.ensure_onlyoffice_available()
result = onlyoffice_service.build_preview_config(
resource_type="version",
resource_id=version.id,
file_name=file_name,
file_hash=version.file_hash,
user_id=current_user.id,
user_name=current_user.full_name,
)
response.headers["Cache-Control"] = "no-store"
return result
def _authorize_internal_file_request(request: Request, expected_url: str) -> None:
onlyoffice_service.validate_outbox_token(request.headers.get("AuthorizationJwt"), expected_url)
@internal_router.get("/internal/onlyoffice/attachments/{attachment_id}/content")
async def get_internal_attachment_content(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
) -> FileResponse:
expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment_id)
_authorize_internal_file_request(request, expected_url)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or not os.path.exists(attachment.file_path):
raise onlyoffice_service.onlyoffice_error(
"ATTACHMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
)
return FileResponse(
path=attachment.file_path,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(attachment.filename)},
)
@internal_router.get("/internal/onlyoffice/versions/{version_id}/content")
async def get_internal_version_content(
version_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
) -> FileResponse:
expected_url = onlyoffice_service.onlyoffice_content_url("version", version_id)
_authorize_internal_file_request(request, expected_url)
version = await version_crud.get(db, version_id)
if not version:
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND
)
document = await document_crud.get(db, version.document_id)
file_path = Path(version.file_uri)
if not document or not file_path.exists():
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
)
file_name = version.original_filename or document_service._legacy_download_filename(version, document)
return FileResponse(
path=str(file_path),
media_type=version.mime_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(file_name)},
)
@internal_router.get("/internal/onlyoffice/collaboration-revisions/{revision_id}/content")
async def get_internal_collaboration_revision_content(
revision_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
) -> FileResponse:
expected_url = onlyoffice_service.onlyoffice_content_url("collaboration_revision", revision_id)
_authorize_internal_file_request(request, expected_url)
revision = await db.get(CollaborationRevision, revision_id)
file_path = Path(revision.file_uri) if revision else None
if not revision or getattr(revision, "deleted_at", None) is not None or not file_path or not file_path.exists():
raise onlyoffice_service.onlyoffice_error(
"COLLABORATION_REVISION_NOT_FOUND", "协作修订不存在", status.HTTP_404_NOT_FOUND
)
return FileResponse(
path=str(file_path),
media_type=revision.mime_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(revision.original_filename)},
)
File diff suppressed because it is too large Load Diff
+1 -39
View File
@@ -1,11 +1,9 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession 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.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud
from app.crud import precaution as precaution_crud from app.crud import precaution as precaution_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.crud import study as study_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="中心已停用") 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( @router.post(
"/precautions", "/precautions",
response_model=PrecautionRead, response_model=PrecautionRead,
@@ -49,16 +42,6 @@ async def create_precaution(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, precaution_in.site_name) 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) 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) return PrecautionRead.model_validate(precaution)
@@ -115,16 +98,6 @@ async def update_precaution(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name) await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in) 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) return PrecautionRead.model_validate(precaution)
@@ -144,15 +117,4 @@ async def delete_precaution(
if not precaution or precaution.study_id != study_id: if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name) 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 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),
)
+6 -2
View File
@@ -1,21 +1,23 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice, collaboration
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_email_settings.router, prefix="/admin", tags=["admin"])
api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(studies.router, prefix="/studies", tags=["studies"]) api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"]) api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"]) api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
api_router.include_router(desktop_notifications.router, prefix="/desktop-notifications", tags=["desktop-notifications"])
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"]) api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"]) api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
api_router.include_router(api_permissions.router, tags=["api-permissions"]) api_router.include_router(api_permissions.router, tags=["api-permissions"])
api_router.include_router(api_permissions.study_router, prefix="/studies/{study_id}", tags=["api-permissions"]) api_router.include_router(api_permissions.study_router, prefix="/studies/{study_id}", tags=["api-permissions"])
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"]) api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"]) api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
api_router.include_router(onlyoffice.router, prefix="/onlyoffice", tags=["onlyoffice"])
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"]) api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"]) api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"]) api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
@@ -28,6 +30,8 @@ api_router.include_router(material_equipments.router, prefix="/studies/{study_id
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"]) api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"]) api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"]) api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
api_router.include_router(collaboration.router, prefix="/studies/{study_id}/collaboration", tags=["collaboration"])
api_router.include_router(collaboration.public_router, prefix="/collaboration/shares", tags=["collaboration-shares"])
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"]) api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"]) api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"]) api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
+56 -6
View File
@@ -1,6 +1,7 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, is_system_admin, require_roles from app.core.deps import get_db_session, is_system_admin, require_roles
@@ -8,7 +9,8 @@ from app.schemas.common import PaginatedResponse
from app.crud import user as user_crud from app.crud import user as user_crud
from app.crud import member as member_crud from app.crud import member as member_crud
from app.utils.pagination import paginate from app.utils.pagination import paginate
from app.schemas.user import UserCreate, UserRead, UserStatus, UserUpdate from app.schemas.user import LoginStatus, UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
from app.services.user_login_sessions import get_login_summaries, list_login_activities, login_activity_payload
router = APIRouter() router = APIRouter()
@@ -19,12 +21,48 @@ async def list_users(
limit: int = 100, limit: int = 100,
keyword: str | None = Query(default=None), keyword: str | None = Query(default=None),
user_status: UserStatus | None = Query(default=None, alias="status"), user_status: UserStatus | None = Query(default=None, alias="status"),
login_status: LoginStatus | None = Query(default=None),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])), current_user=Depends(require_roles(["ADMIN"])),
) -> PaginatedResponse[UserRead]: ) -> PaginatedResponse[UserRead]:
users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status) users = await user_crud.list_users(
total_users = await user_crud.count_users(db, keyword=keyword, status=user_status) db, skip=skip, limit=limit, keyword=keyword, status=user_status, login_status=login_status
return paginate(list(users), total=total_users) )
total_users = await user_crud.count_users(
db, keyword=keyword, status=user_status, login_status=login_status
)
summaries = await get_login_summaries(db, [user.id for user in users])
items = []
for user in users:
summary = summaries.get(user.id)
item = UserRead.model_validate(user).model_dump()
if summary:
item.update(
{
"login_status": summary.status,
"last_login_at": summary.last_login_at,
"last_seen_at": summary.last_seen_at,
"last_client_type": summary.client_type,
"active_session_count": summary.active_session_count,
}
)
items.append(item)
return paginate(items, total=total_users)
@router.get("/{user_id}/login-activities", response_model=list[UserLoginActivityRead])
async def read_user_login_activities(
user_id: uuid.UUID,
response: Response,
limit: int = Query(default=30, ge=1, le=100),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> list[UserLoginActivityRead]:
response.headers["Cache-Control"] = "no-store"
if not await user_crud.get_by_id(db, user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
activities = await list_login_activities(db, user_id=user_id, limit=limit)
return [login_activity_payload(activity) for activity in activities]
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
@@ -97,4 +135,16 @@ async def delete_user(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="该账号仍在项目成员中,请先在项目成员配置中移除后再删除", detail="该账号仍在项目成员中,请先在项目成员配置中移除后再删除",
) )
await user_crud.delete_user(db, db_user) if await user_crud.user_has_retained_history(db, db_user.id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该账号已有审计或权限访问记录,请停用账号以保留历史追溯",
)
try:
await user_crud.delete_user(db, db_user)
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该账号已有业务记录,请停用账号以保留历史追溯",
) from exc
+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.crud import visit as visit_crud
from app.schemas.subject import SubjectUpdate from app.schemas.subject import SubjectUpdate
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate
from app.services import notification_service
router = APIRouter() router = APIRouter()
@@ -42,6 +43,17 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用") 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( @router.get(
"/", "/",
response_model=list[VisitRead], 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: if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期") 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: try:
visit = await visit_crud.create_early_termination_visit( visit = await visit_crud.create_early_termination_visit(
db, db,
@@ -164,6 +178,7 @@ async def create_early_termination(
drop_reason=reason, drop_reason=reason,
), ),
) )
await _resolve_visit_reminders(db, existing_visit_ids)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
@@ -209,6 +224,8 @@ async def update_visit(
old_status = visit.status old_status = visit.status
updated = await visit_crud.update_visit(db, visit, visit_in) updated = await visit_crud.update_visit(db, visit, visit_in)
await subject_crud.sync_subject_status(db, subject) 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 detail = None
if visit_in.status: if visit_in.status:
detail = json.dumps( detail = json.dumps(
@@ -253,6 +270,7 @@ async def delete_visit(
visit_detail = _visit_audit_detail("删除", subject, visit) visit_detail = _visit_audit_detail("删除", subject, visit)
await visit_crud.delete_visit(db, visit) await visit_crud.delete_visit(db, visit)
await subject_crud.sync_subject_status(db, subject) await subject_crud.sync_subject_status(db, subject)
await _resolve_visit_reminders(db, [visit_id])
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
+43
View File
@@ -643,6 +643,43 @@ API_ENDPOINT_PERMISSIONS = {
"description": "删除文档", "description": "删除文档",
"default_roles": ["PM"], "default_roles": ["PM"],
}, },
# 共享库在线协作(文档级邀请权限在接口权限之后继续校验)
"collaboration:create": {
"module": "shared_library",
"action": "write",
"description": "创建在线协作文件",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"collaboration:read": {
"module": "shared_library",
"action": "read",
"description": "查看在线协作文件",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"collaboration:edit": {
"module": "shared_library",
"action": "write",
"description": "编辑受邀在线协作文件",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"collaboration:manage": {
"module": "shared_library",
"action": "write",
"description": "管理在线协作文件成员与目录",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"collaboration:export": {
"module": "shared_library",
"action": "export",
"description": "另存为在线协作文件副本",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"collaboration:delete": {
"module": "shared_library",
"action": "write",
"description": "移入或恢复在线协作文件",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
} }
def _operation_read_candidates(operation_key: str) -> list[str]: def _operation_read_candidates(operation_key: str) -> list[str]:
@@ -843,6 +880,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"precautions:read", "precautions:read",
"faq:read", "faq:read",
"faq_category:read", "faq_category:read",
"collaboration:read",
], ],
"write": [ "write": [
"precautions:create", "precautions:create",
@@ -858,6 +896,11 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"faq_reply:create", "faq_reply:create",
"faq_reply:delete", "faq_reply:delete",
"faq_attachments:delete", "faq_attachments:delete",
"collaboration:create",
"collaboration:edit",
"collaboration:manage",
"collaboration:export",
"collaboration:delete",
], ],
}, },
} }
+65
View File
@@ -1,5 +1,6 @@
from functools import lru_cache from functools import lru_cache
from typing import Literal, Optional from typing import Literal, Optional
from urllib.parse import urlsplit
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -19,13 +20,45 @@ class Settings(BaseSettings):
JWT_EXPIRE_MINUTES: int = 60 JWT_EXPIRE_MINUTES: int = 60
JWT_EXTEND_GRACE_SECONDS: int = 120 JWT_EXTEND_GRACE_SECONDS: int = 120
ABSOLUTE_SESSION_MAX_HOURS: int = 8 ABSOLUTE_SESSION_MAX_HOURS: int = 8
DESKTOP_SESSION_MAX_DAYS: int = 30
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
LOGIN_RSA_KEY_ID: str = "default" LOGIN_RSA_KEY_ID: str = "default"
LOGIN_CHALLENGE_TTL_SECONDS: int = 120 LOGIN_CHALLENGE_TTL_SECONDS: int = 120
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000 LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
SETTINGS_ENCRYPTION_KEY: Optional[str] = None
FRONTEND_PUBLIC_URL: str = "http://localhost:8888"
CORS_ALLOWED_ORIGINS: str = (
"http://localhost:8888,http://localhost:5173,"
"tauri://localhost,http://tauri.localhost"
)
IP2REGION_XDB_PATH: Optional[str] = None IP2REGION_XDB_PATH: Optional[str] = None
IP2REGION_IPV6_XDB_PATH: Optional[str] = None IP2REGION_IPV6_XDB_PATH: Optional[str] = None
TRUSTED_PROXY_CIDRS: str = "127.0.0.1/32,::1/128,172.16.0.0/12"
MONITORING_SERVER_PUBLIC_IP: Optional[str] = None
MONITORING_PUBLIC_IP_DISCOVERY_URLS: str = (
"https://api64.ipify.org,https://icanhazip.com"
)
MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
MONITORING_SERVER_LOCATION_CACHE_SECONDS: int = Field(default=86400, ge=300, le=604800)
MONITORING_IP_GEO_FALLBACK_ENABLED: bool = True
MONITORING_IP_GEO_FALLBACK_API_KEY: Optional[str] = None
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: int = Field(default=604800, ge=3600, le=2592000)
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: int = Field(default=10, ge=1, le=100)
MONITORING_ACCESS_LOG_RETENTION_DAYS: int = Field(default=90, ge=7, le=3650)
MONITORING_METRIC_RETENTION_DAYS: int = Field(default=400, ge=30, le=3650)
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"
ONLYOFFICE_STORAGE_BASE_URL: str = "http://backend:8000"
ONLYOFFICE_INSTANCE_ID: Optional[str] = None
ONLYOFFICE_CONFIG_TTL_SECONDS: int = Field(default=300, ge=60, le=900)
COLLABORATION_MAX_FILE_BYTES: int = Field(default=50 * 1024 * 1024, ge=1024, le=500 * 1024 * 1024)
@lru_cache @lru_cache
@@ -34,3 +67,35 @@ def get_settings() -> Settings:
settings = get_settings() settings = get_settings()
def validate_onlyoffice_configuration() -> None:
if not settings.ONLYOFFICE_ENABLED:
return
secret = (settings.ONLYOFFICE_JWT_SECRET or "").strip()
instance_id = (settings.ONLYOFFICE_INSTANCE_ID or "").strip()
if secret == settings.JWT_SECRET_KEY:
raise RuntimeError("ONLYOFFICE_JWT_SECRET must not reuse JWT_SECRET_KEY")
if len(secret) < 32:
raise RuntimeError("ONLYOFFICE_JWT_SECRET must contain at least 32 characters when ONLYOFFICE is enabled")
if not instance_id:
raise RuntimeError("ONLYOFFICE_INSTANCE_ID is required when ONLYOFFICE is enabled")
for name, value in (
("ONLYOFFICE_INTERNAL_URL", settings.ONLYOFFICE_INTERNAL_URL),
("ONLYOFFICE_STORAGE_BASE_URL", settings.ONLYOFFICE_STORAGE_BASE_URL),
):
parsed = urlsplit(value.strip())
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
raise RuntimeError(f"{name} must be an HTTP(S) URL")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise RuntimeError(f"{name} must not contain credentials, a query, or a fragment")
if parsed.path not in {"", "/"}:
raise RuntimeError(f"{name} must not contain a path")
def get_cors_allowed_origins() -> list[str]:
return [
origin.strip()
for origin in settings.CORS_ALLOWED_ORIGINS.split(",")
if origin.strip()
]
+13 -3
View File
@@ -286,8 +286,9 @@ def _enqueue_permission_log(
writer = get_log_writer() writer = get_log_writer()
if writer: if writer:
forwarded = request.headers.get("x-forwarded-for") from app.core.request_context import build_request_audit_context, get_request_audit_context
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None)
context = get_request_audit_context() or build_request_audit_context(request)
writer.enqueue({ writer.enqueue({
"study_id": study_id, "study_id": study_id,
"user_id": user_id, "user_id": user_id,
@@ -295,7 +296,16 @@ def _enqueue_permission_log(
"role": role, "role": role,
"allowed": allowed, "allowed": allowed,
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"ip_address": ip, "ip_address": context.client_ip,
"user_agent": context.user_agent,
"client_type": context.client_type,
"client_version": context.client_version,
"client_platform": context.client_platform,
"build_channel": context.build_channel,
"build_commit": context.build_commit,
"request_headers": context.request_headers,
"request_snapshot": context.request_snapshot,
"request_id": context.request_id,
}) })
+15
View File
@@ -44,8 +44,12 @@ class PermissionCache:
if cache_key in self._member_role_cache: if cache_key in self._member_role_cache:
cached_role, timestamp = self._member_role_cache[cache_key] cached_role, timestamp = self._member_role_cache[cache_key]
if not self._is_expired(timestamp, ttl): if not self._is_expired(timestamp, ttl):
from app.core.permission_monitor import get_permission_monitor
get_permission_monitor().record_cache_hit("member_roles")
return cached_role return cached_role
from app.core.permission_monitor import get_permission_monitor
get_permission_monitor().record_cache_miss("member_roles")
membership = await member_crud.get_member(db, study_id, user_id) membership = await member_crud.get_member(db, study_id, user_id)
role = membership.role_in_study if membership and membership.is_active else None role = membership.role_in_study if membership and membership.is_active else None
@@ -102,6 +106,17 @@ class PermissionCache:
self._member_role_cache.clear() self._member_role_cache.clear()
def get_cache_stats(self) -> dict[str, Any]: def get_cache_stats(self) -> dict[str, Any]:
now = time.time()
self._project_permissions_cache = {
key: value
for key, value in self._project_permissions_cache.items()
if now - value[1] <= self.default_ttl
}
self._member_role_cache = {
key: value
for key, value in self._member_role_cache.items()
if now - value[1] <= self.default_ttl
}
return { return {
"project_permissions_count": len(self._project_permissions_cache), "project_permissions_count": len(self._project_permissions_cache),
"member_role_count": len(self._member_role_cache), "member_role_count": len(self._member_role_cache),
+64 -10
View File
@@ -8,13 +8,15 @@ permission_access_logs 表中,通过 /metrics 端点实时聚合查询。
from __future__ import annotations from __future__ import annotations
import time import time
from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from app.core.permission_cache import get_permission_cache from app.core.permission_cache import get_permission_cache
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 10 CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 50
CACHE_METRICS_WINDOW_SECONDS = 3600
@dataclass @dataclass
@@ -25,6 +27,11 @@ class CacheMetrics:
cache_hits: int = 0 cache_hits: int = 0
cache_misses: int = 0 cache_misses: int = 0
cache_invalidations: int = 0 cache_invalidations: int = 0
_access_events: deque[tuple[float, str, bool]] = field(
default_factory=deque,
repr=False,
)
_invalidation_events: deque[float] = field(default_factory=deque, repr=False)
@property @property
def hit_rate(self) -> float: def hit_rate(self) -> float:
@@ -48,6 +55,53 @@ class CacheMetrics:
"miss_rate": round(self.miss_rate, 2), "miss_rate": round(self.miss_rate, 2),
} }
def record_access(self, cache_name: str, *, hit: bool) -> None:
self.total_accesses += 1
if hit:
self.cache_hits += 1
else:
self.cache_misses += 1
self._access_events.append((time.time(), cache_name, hit))
def record_invalidation(self) -> None:
self.cache_invalidations += 1
self._invalidation_events.append(time.time())
def window_stats(self, seconds: int = CACHE_METRICS_WINDOW_SECONDS) -> dict[str, Any]:
cutoff = time.time() - seconds
while self._access_events and self._access_events[0][0] < cutoff:
self._access_events.popleft()
while self._invalidation_events and self._invalidation_events[0] < cutoff:
self._invalidation_events.popleft()
def summarize(cache_name: str | None = None) -> dict[str, Any]:
events = [
event for event in self._access_events
if cache_name is None or event[1] == cache_name
]
hits = sum(event[2] for event in events)
total = len(events)
misses = total - hits
return {
"total_accesses": total,
"cache_hits": hits,
"cache_misses": misses,
"cache_invalidations": len(self._invalidation_events),
"hit_rate": round(hits / total * 100, 2) if total else 0.0,
"miss_rate": round(misses / total * 100, 2) if total else 0.0,
}
stats = summarize()
stats.update({
"window_seconds": seconds,
"scope": "sliding_window",
"by_cache": {
"project_permissions": summarize("project_permissions"),
"member_roles": summarize("member_roles"),
},
})
return stats
@dataclass @dataclass
class PermissionSystemMetrics: class PermissionSystemMetrics:
@@ -70,16 +124,14 @@ class PermissionMonitor:
self.metrics = PermissionSystemMetrics() self.metrics = PermissionSystemMetrics()
self._alerts: list[dict[str, Any]] = [] self._alerts: list[dict[str, Any]] = []
def record_cache_hit(self) -> None: def record_cache_hit(self, cache_name: str = "project_permissions") -> None:
self.metrics.cache_metrics.total_accesses += 1 self.metrics.cache_metrics.record_access(cache_name, hit=True)
self.metrics.cache_metrics.cache_hits += 1
def record_cache_miss(self) -> None: def record_cache_miss(self, cache_name: str = "project_permissions") -> None:
self.metrics.cache_metrics.total_accesses += 1 self.metrics.cache_metrics.record_access(cache_name, hit=False)
self.metrics.cache_metrics.cache_misses += 1
def record_cache_invalidation(self) -> None: def record_cache_invalidation(self) -> None:
self.metrics.cache_metrics.cache_invalidations += 1 self.metrics.cache_metrics.record_invalidation()
def record_error_alert(self, error: Exception) -> None: def record_error_alert(self, error: Exception) -> None:
self._add_alert( self._add_alert(
@@ -119,12 +171,14 @@ class PermissionMonitor:
cache = get_permission_cache() cache = get_permission_cache()
return { return {
"cache_items": cache.get_cache_stats(), "cache_items": cache.get_cache_stats(),
"cache_metrics": self.metrics.cache_metrics.to_dict(), "cache_metrics": self.metrics.cache_metrics.window_stats(),
"cache_metrics_lifetime": self.metrics.cache_metrics.to_dict(),
} }
def get_metrics(self) -> dict[str, Any]: def get_metrics(self) -> dict[str, Any]:
return { return {
"cache_metrics": self.metrics.cache_metrics.to_dict(), "cache_metrics": self.metrics.cache_metrics.window_stats(),
"cache_metrics_lifetime": self.metrics.cache_metrics.to_dict(),
"uptime_seconds": self.metrics.uptime_seconds, "uptime_seconds": self.metrics.uptime_seconds,
} }
+254
View File
@@ -0,0 +1,254 @@
"""Request-scoped metadata used by server-side audit writers."""
from __future__ import annotations
import ipaddress
import re
import uuid
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Any
from app.core.config import settings
@dataclass(frozen=True)
class RequestAuditContext:
request_id: str | None = None
client_ip: str | None = None
user_agent: str | None = None
client_type: str | None = None
client_version: str | None = None
client_platform: str | None = None
build_channel: str | None = None
build_commit: str | None = None
request_headers: dict[str, str] | None = None
request_snapshot: dict[str, Any] | None = None
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
"request_audit_context",
default=None,
)
_SENSITIVE_TEXT_PATTERN = re.compile(
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization|password|passwd|secret|credential|api[-_]?key|session)=([^&\s]+)"
)
_SENSITIVE_HEADER_NAME_PATTERN = re.compile(
r"(?i)(authorization|cookie|set-cookie|token|password|passwd|secret|credential|api[-_]?key|session)"
)
_SAFE_REQUEST_HEADER_NAMES = frozenset(
{
"accept",
"accept-language",
"content-type",
"host",
"origin",
"user-agent",
"x-request-id",
"x-correlation-id",
}
)
_SAFE_REQUEST_HEADER_PREFIXES = ("x-ctms-",)
_SENSITIVE_PARAMETER_NAME_PATTERN = re.compile(
r"(?i)(token|authorization|password|passwd|secret|credential|api[-_]?key|session|"
r"subject|participant|patient|user_?name|full_?name|email|phone|mobile|id_?card|"
r"identity|certificate|contact|address)"
)
_KNOWN_CLIENT_TYPES = frozenset({"web", "desktop"})
_CTMS_CLIENT_SOURCE_TO_TYPE = {
"ctms-web": "web",
"ctms-desktop": "desktop",
}
def _clean_audit_value(value: str | None, max_length: int) -> str | None:
if value is None:
return None
cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip())
if not cleaned:
return None
return cleaned[:max_length]
def _clean_header_value(value: str | None, max_length: int) -> str | None:
return _clean_audit_value(value, max_length)
def resolve_ctms_client_type(headers: Any) -> str | None:
source = _clean_header_value(headers.get("x-ctms-client-source"), 32)
if source:
mapped_type = _CTMS_CLIENT_SOURCE_TO_TYPE.get(source.strip().lower())
if mapped_type:
return mapped_type
client_type = _clean_header_value(headers.get("x-ctms-client-type"), 16)
if not client_type:
return None
normalized = client_type.strip().lower()
return normalized if normalized in _KNOWN_CLIENT_TYPES else normalized[:16]
def build_sanitized_request_headers(request: Any) -> dict[str, str] | None:
captured: dict[str, str] = {}
for raw_name, raw_value in request.headers.items():
name = str(raw_name).strip().lower()
if not name:
continue
if _SENSITIVE_HEADER_NAME_PATTERN.search(name):
captured[name] = "[redacted]"
continue
if name not in _SAFE_REQUEST_HEADER_NAMES and not name.startswith(_SAFE_REQUEST_HEADER_PREFIXES):
continue
value = _clean_header_value(str(raw_value), 500)
if value:
captured[name] = value
return captured or None
def _sanitize_named_value(raw_name: Any, raw_value: Any, max_length: int = 500) -> dict[str, str] | None:
name = _clean_audit_value(str(raw_name), 120)
if not name:
return None
if _SENSITIVE_PARAMETER_NAME_PATTERN.search(name):
return {"name": name, "value": "[redacted]"}
value = _clean_audit_value(str(raw_value), max_length)
if value is None:
return {"name": name, "value": ""}
return {"name": name, "value": value}
def _query_param_items(request: Any) -> list[dict[str, str]] | None:
query_params = getattr(request, "query_params", None)
if not query_params:
return None
if hasattr(query_params, "multi_items"):
raw_items = query_params.multi_items()
else:
raw_items = query_params.items()
items = [
item
for item in (_sanitize_named_value(name, value) for name, value in raw_items)
if item is not None
][:50]
return items or None
def _sanitized_query_string(items: list[dict[str, str]] | None) -> str | None:
if not items:
return None
value = "&".join(f"{item['name']}={item['value']}" for item in items)
return value[:2000] or None
def _request_client_snapshot(request: Any) -> dict[str, str | int | None] | None:
client = getattr(request, "client", None)
if not client:
return None
host = _clean_audit_value(getattr(client, "host", None), 120)
port = getattr(client, "port", None)
if host is None and port is None:
return None
return {"host": host, "port": port}
def build_request_snapshot(request: Any, *, request_id: str | None = None) -> dict[str, Any]:
headers = getattr(request, "headers", {})
scope = getattr(request, "scope", {}) or {}
url = getattr(request, "url", None)
query_params = _query_param_items(request)
path = getattr(url, "path", None) or scope.get("path")
method = getattr(request, "method", None) or scope.get("method")
snapshot: dict[str, Any] = {
"request_id": request_id,
"method": _clean_audit_value(str(method).upper() if method else None, 12),
"path": _clean_audit_value(path, 500),
"query_string": _sanitized_query_string(query_params),
"query_params": query_params,
"headers": build_sanitized_request_headers(request),
"http_version": _clean_audit_value(scope.get("http_version"), 16),
"scheme": _clean_audit_value(getattr(url, "scheme", None) or scope.get("scheme"), 16),
"client": _request_client_snapshot(request),
"content": {
"type": _clean_audit_value(headers.get("content-type"), 200),
"length": _clean_audit_value(headers.get("content-length"), 32),
},
"body": {
"captured": False,
"reason": "body_not_captured_by_audit_policy",
},
}
return {key: value for key, value in snapshot.items() if value not in (None, {}, [])}
def _parse_ip(value: str | None):
try:
return ipaddress.ip_address((value or "").strip())
except ValueError:
return None
def _trusted_proxy_networks():
networks = []
for value in settings.TRUSTED_PROXY_CIDRS.split(","):
candidate = value.strip()
if not candidate:
continue
try:
networks.append(ipaddress.ip_network(candidate, strict=False))
except ValueError:
continue
return tuple(networks)
def _is_trusted_proxy(value: str | None) -> bool:
address = _parse_ip(value)
return bool(address and any(address in network for network in _trusted_proxy_networks()))
def resolve_client_ip(request: Any) -> str | None:
peer_ip = request.client.host if request.client else None
if not _is_trusted_proxy(peer_ip):
return _clean_header_value(peer_ip, 45)
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
chain = [part.strip() for part in forwarded.split(",") if _parse_ip(part)]
chain.append(str(peer_ip))
for candidate in reversed(chain):
if not _is_trusted_proxy(candidate):
return _clean_header_value(candidate, 45)
real_ip = request.headers.get("x-real-ip")
if _parse_ip(real_ip):
return _clean_header_value(real_ip.strip(), 45)
return _clean_header_value(peer_ip, 45)
def build_request_audit_context(request: Any) -> RequestAuditContext:
headers = request.headers
request_id = str(uuid.uuid4())
return RequestAuditContext(
request_id=request_id,
client_ip=resolve_client_ip(request),
user_agent=_clean_header_value(headers.get("user-agent"), 500),
client_type=resolve_ctms_client_type(headers),
client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32),
client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16),
build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16),
build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64),
request_headers=build_sanitized_request_headers(request),
request_snapshot=build_request_snapshot(request, request_id=request_id),
)
def set_request_audit_context(context: RequestAuditContext) -> Token[RequestAuditContext | None]:
return _request_audit_context.set(context)
def reset_request_audit_context(token: Token[RequestAuditContext | None]) -> None:
_request_audit_context.reset(token)
def get_request_audit_context() -> RequestAuditContext | None:
return _request_audit_context.get()
+17 -1
View File
@@ -19,16 +19,32 @@ def create_access_token(
user_id: str, user_id: str,
expires_minutes: Optional[int] = None, expires_minutes: Optional[int] = None,
session_start: Optional[datetime] = None, session_start: Optional[datetime] = None,
max_age_seconds: Optional[int] = None,
issued_at: Optional[datetime] = None,
client_type: Optional[str] = None,
session_id: Optional[str] = None,
) -> str: ) -> str:
now = datetime.now(timezone.utc) now = issued_at or datetime.now(timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES) expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
session_start_time = session_start or now session_start_time = session_start or now
if session_start_time.tzinfo is None:
session_start_time = session_start_time.replace(tzinfo=timezone.utc)
if max_age_seconds is not None:
session_expire = session_start_time + timedelta(seconds=max_age_seconds)
if expire > session_expire:
expire = session_expire
to_encode: Dict[str, Any] = { to_encode: Dict[str, Any] = {
"sub": user_id, "sub": user_id,
"exp": expire, "exp": expire,
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
"orig_iat": int(session_start_time.timestamp()), "orig_iat": int(session_start_time.timestamp()),
} }
if client_type:
to_encode["client_type"] = client_type
if session_id:
to_encode["sid"] = session_id
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
+39
View File
@@ -4,8 +4,10 @@ import uuid
from typing import Sequence from typing import Sequence
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy import or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.request_context import get_request_audit_context
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
@@ -19,8 +21,16 @@ async def log_action(
detail: str | None, detail: str | None,
operator_id: uuid.UUID, operator_id: uuid.UUID,
operator_role: str, operator_role: str,
client_ip: str | None = None,
user_agent: str | None = None,
client_type: str | None = None,
client_version: str | None = None,
client_platform: str | None = None,
build_channel: str | None = None,
build_commit: str | None = None,
auto_commit: bool = True, auto_commit: bool = True,
) -> AuditLog: ) -> AuditLog:
request_context = get_request_audit_context()
log = AuditLog( log = AuditLog(
study_id=study_id, study_id=study_id,
entity_type=entity_type, entity_type=entity_type,
@@ -29,6 +39,13 @@ async def log_action(
detail=detail, detail=detail,
operator_id=operator_id, operator_id=operator_id,
operator_role=operator_role, operator_role=operator_role,
client_ip=client_ip if client_ip is not None else (request_context.client_ip if request_context else None),
user_agent=user_agent if user_agent is not None else (request_context.user_agent if request_context else None),
client_type=client_type if client_type is not None else (request_context.client_type if request_context else None),
client_version=client_version if client_version is not None else (request_context.client_version if request_context else None),
client_platform=client_platform if client_platform is not None else (request_context.client_platform if request_context else None),
build_channel=build_channel if build_channel is not None else (request_context.build_channel if request_context else None),
build_commit=build_commit if build_commit is not None else (request_context.build_commit if request_context else None),
) )
db.add(log) db.add(log)
if auto_commit: if auto_commit:
@@ -47,6 +64,10 @@ async def list_logs(
entity_id: uuid.UUID | None = None, entity_id: uuid.UUID | None = None,
action: str | None = None, action: str | None = None,
operator_id: uuid.UUID | None = None, operator_id: uuid.UUID | None = None,
client_ip: str | None = None,
client_type: str | None = None,
start_time=None,
end_time=None,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
) -> Sequence[AuditLog]: ) -> Sequence[AuditLog]:
@@ -59,6 +80,24 @@ async def list_logs(
stmt = stmt.where(AuditLog.action == action) stmt = stmt.where(AuditLog.action == action)
if operator_id: if operator_id:
stmt = stmt.where(AuditLog.operator_id == operator_id) stmt = stmt.where(AuditLog.operator_id == operator_id)
if client_ip:
stmt = stmt.where(AuditLog.client_ip.ilike(f"%{client_ip.strip()}%"))
if client_type:
normalized_client_type = client_type.strip().lower()
if normalized_client_type == "unknown":
stmt = stmt.where(
or_(
AuditLog.client_type.is_(None),
AuditLog.client_type == "",
~AuditLog.client_type.in_(("web", "desktop")),
)
)
else:
stmt = stmt.where(AuditLog.client_type == normalized_client_type)
if start_time:
stmt = stmt.where(AuditLog.created_at >= start_time)
if end_time:
stmt = stmt.where(AuditLog.created_at <= end_time)
stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit) stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalars().all() return result.scalars().all()
+49 -28
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Sequence from datetime import datetime, timedelta, timezone
from typing import Literal, Sequence
from sqlalchemy import delete, func, or_, select, update from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,10 +12,14 @@ from app.core.config import (
PROTECTED_ADMIN_DEFAULT_PASSWORD, PROTECTED_ADMIN_DEFAULT_PASSWORD,
PROTECTED_ADMIN_EMAIL, PROTECTED_ADMIN_EMAIL,
PROTECTED_ADMIN_FULL_NAME, PROTECTED_ADMIN_FULL_NAME,
settings,
) )
from app.core.security import hash_password from app.core.security import hash_password
from app.models.audit_log import AuditLog
from app.models.permission_access_log import PermissionAccessLog
from app.models.study_member import StudyMember from app.models.study_member import StudyMember
from app.models.user import User, UserStatus from app.models.user import User, UserStatus
from app.models.user_login_session import UserLoginSession
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
@@ -53,8 +58,8 @@ async def create_user(
return user return user
async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) -> User: async def create_registered_user(db: AsyncSession, user_in: UserRegisterRequest) -> User:
return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.PENDING) return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.ACTIVE)
async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User: async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
@@ -79,7 +84,13 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User
return user return user
def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus | None = None): def _apply_user_filters(
query,
*,
keyword: str | None = None,
status: UserStatus | None = None,
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
):
if keyword: if keyword:
pattern = f"%{keyword.strip()}%" pattern = f"%{keyword.strip()}%"
query = query.where( query = query.where(
@@ -91,6 +102,18 @@ def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus
) )
if status is not None: if status is not None:
query = query.where(User.status == status) query = query.where(User.status == status)
if login_status is not None:
cutoff = datetime.now(timezone.utc) - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS)
has_online_session = (
select(UserLoginSession.id)
.where(
UserLoginSession.user_id == User.id,
UserLoginSession.ended_at.is_(None),
UserLoginSession.last_seen_at >= cutoff,
)
.exists()
)
query = query.where(has_online_session if login_status == "ONLINE" else ~has_online_session)
return query return query
@@ -101,8 +124,9 @@ async def list_users(
*, *,
keyword: str | None = None, keyword: str | None = None,
status: UserStatus | None = None, status: UserStatus | None = None,
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
) -> Sequence[User]: ) -> Sequence[User]:
query = _apply_user_filters(select(User), keyword=keyword, status=status) query = _apply_user_filters(select(User), keyword=keyword, status=status, login_status=login_status)
result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit)) result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit))
return result.scalars().all() return result.scalars().all()
@@ -112,8 +136,14 @@ async def count_users(
*, *,
keyword: str | None = None, keyword: str | None = None,
status: UserStatus | None = None, status: UserStatus | None = None,
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
) -> int: ) -> int:
query = _apply_user_filters(select(func.count()).select_from(User), keyword=keyword, status=status) query = _apply_user_filters(
select(func.count()).select_from(User),
keyword=keyword,
status=status,
login_status=login_status,
)
result = await db.execute(query) result = await db.execute(query)
return int(result.scalar_one() or 0) return int(result.scalar_one() or 0)
@@ -150,6 +180,19 @@ async def count_active_admins(db: AsyncSession) -> int:
return int(result.scalar_one() or 0) return int(result.scalar_one() or 0)
async def user_has_retained_history(db: AsyncSession, user_id: uuid.UUID) -> bool:
audit_count = await db.scalar(
select(func.count()).select_from(AuditLog).where(AuditLog.operator_id == user_id)
)
if int(audit_count or 0) > 0:
return True
permission_log_count = await db.scalar(
select(func.count()).select_from(PermissionAccessLog).where(PermissionAccessLog.user_id == user_id)
)
return int(permission_log_count or 0) > 0
async def list_users_by_status( async def list_users_by_status(
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100 db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
) -> Sequence[User]: ) -> Sequence[User]:
@@ -189,25 +232,3 @@ async def delete_user(db: AsyncSession, user: User) -> None:
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id)) await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
await db.delete(user) await db.delete(user)
await db.commit() await db.commit()
async def approve_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
await db.execute(
update(User)
.where(User.id == user.id)
.values(status=UserStatus.ACTIVE, approved_by=admin_id, approved_at=func.now())
)
await db.commit()
await db.refresh(user)
return user
async def reject_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
await db.execute(
update(User)
.where(User.id == user.id)
.values(status=UserStatus.REJECTED, approved_by=admin_id, approved_at=func.now())
)
await db.commit()
await db.refresh(user)
return user
+18
View File
@@ -10,6 +10,16 @@ from app.models.audit_log import AuditLog # noqa: F401
from app.models.etmf import EtmfNode # noqa: F401 from app.models.etmf import EtmfNode # noqa: F401
from app.models.document import Document # noqa: F401 from app.models.document import Document # noqa: F401
from app.models.document_version import DocumentVersion # noqa: F401 from app.models.document_version import DocumentVersion # noqa: F401
from app.models.collaboration import ( # noqa: F401
CollaborationCallbackReceipt,
CollaborationEditRequest,
CollaborationFile,
CollaborationFolder,
CollaborationMember,
CollaborationRevision,
CollaborationSession,
CollaborationShareLink,
)
from app.models.distribution import Distribution # noqa: F401 from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # noqa: F401 from app.models.acknowledgement import Acknowledgement # noqa: F401
from app.models.milestone import Milestone # noqa: F401 from app.models.milestone import Milestone # noqa: F401
@@ -42,3 +52,11 @@ from app.models.permission_access_log import PermissionAccessLog # noqa: F401
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401 from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401 from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401
from app.models.security_access_log import SecurityAccessLog # noqa: F401 from app.models.security_access_log import SecurityAccessLog # noqa: F401
from app.models.source_location_snapshot import SourceLocationSnapshot # noqa: F401
from app.models.user_login_session import UserLoginSession # noqa: F401
from app.models.desktop_notification import ( # noqa: F401
DesktopNotificationDelivery,
DesktopNotificationSubscription,
)
from app.models.notification import Notification # noqa: F401
from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401
+141 -62
View File
@@ -5,26 +5,44 @@ import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from collections import defaultdict from collections import defaultdict
from fastapi import FastAPI from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text from sqlalchemy import text
from app.api.v1.router import api_router from app.api.v1.router import api_router
from app.core.config import settings from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_router
from app.api.v1.collaboration import internal_router as collaboration_internal_router
from app.core.config import get_cors_allowed_origins, settings, validate_onlyoffice_configuration
from app.core.exceptions import register_exception_handlers from app.core.exceptions import register_exception_handlers
from app.core.login_crypto import validate_login_crypto_configuration from app.core.login_crypto import validate_login_crypto_configuration
from app.crud.user import ensure_admin_exists from app.crud.user import ensure_admin_exists
from app.db.base import Base from app.db.base import Base
from app.db.session import SessionLocal, engine from app.db.session import SessionLocal, engine
from app.services.visit_scheduler import run_daily_lost_visit_job 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_log_writer import start_log_writer, stop_log_writer
from app.services.permission_metric_aggregator import run_hourly_metric_aggregation from app.services.permission_metric_aggregator import run_hourly_metric_aggregation
from app.services.source_location_aggregator import run_hourly_source_location_aggregation
from app.services.monitoring_server_location import resolve_monitoring_server_location
from app.services.monitoring_retention import run_monitoring_retention
from app.services.security_access_log_writer import ( from app.services.security_access_log_writer import (
get_security_log_writer, get_security_log_writer,
start_security_log_writer, start_security_log_writer,
stop_security_log_writer, stop_security_log_writer,
) )
from app.core.security import decode_token from app.core.security import decode_token
from app.core.deps import get_db_session
from app.core.request_context import (
build_request_audit_context,
build_request_snapshot,
build_sanitized_request_headers,
get_request_audit_context,
reset_request_audit_context,
resolve_ctms_client_type,
resolve_client_ip,
set_request_audit_context,
)
logger = logging.getLogger("ctms.setup_config") logger = logging.getLogger("ctms.setup_config")
UUID_RE = re.compile( UUID_RE = re.compile(
@@ -36,8 +54,12 @@ setup_config_stats: dict[str, int] = defaultdict(int)
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
stop_event = asyncio.Event() stop_event = asyncio.Event()
await resolve_monitoring_server_location()
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event)) scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event)) aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event))
source_location_aggregator_task = asyncio.create_task(
run_hourly_source_location_aggregation(stop_event)
)
await start_log_writer() await start_log_writer()
await start_security_log_writer() await start_security_log_writer()
if settings.ENV == "development": if settings.ENV == "development":
@@ -46,12 +68,17 @@ async def lifespan(_: FastAPI):
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session: async with SessionLocal() as session:
await ensure_admin_exists(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 yield
stop_event.set() stop_event.set()
await stop_log_writer() await stop_log_writer()
await stop_security_log_writer() await stop_security_log_writer()
await scheduler_task await scheduler_task
await aggregator_task await aggregator_task
await source_location_aggregator_task
await notification_scheduler_task
await retention_task
async def _ensure_legacy_primary_keys(conn) -> None: async def _ensure_legacy_primary_keys(conn) -> None:
@@ -89,6 +116,7 @@ async def _ensure_legacy_primary_keys(conn) -> None:
def create_app() -> FastAPI: def create_app() -> FastAPI:
validate_login_crypto_configuration() validate_login_crypto_configuration()
validate_onlyoffice_configuration()
app = FastAPI( app = FastAPI(
title="CTMS 后端 API", title="CTMS 后端 API",
description="临床试验项目管理系统后端接口文档", description="临床试验项目管理系统后端接口文档",
@@ -116,72 +144,92 @@ def create_app() -> FastAPI:
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=get_cors_allowed_origins(),
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=[
"Accept",
"Authorization",
"Content-Type",
"X-CTMS-Client-Source",
"X-CTMS-Client-Type",
"X-CTMS-Client-Version",
"X-CTMS-Client-Platform",
"X-CTMS-Build-Channel",
"X-CTMS-Build-Commit",
"X-Request-ID",
"X-Correlation-ID",
],
expose_headers=["X-Request-ID"],
) )
@app.middleware("http") @app.middleware("http")
async def setup_config_monitoring_middleware(request, call_next): async def setup_config_monitoring_middleware(request, call_next):
path = request.url.path path = request.url.path
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
should_security_log = path.startswith("/api/") should_security_log = path.startswith("/api/") and path != "/api/v1/auth/session/heartbeat"
started_at = time.perf_counter() started_at = time.perf_counter()
audit_context = build_request_audit_context(request)
audit_context_token = set_request_audit_context(audit_context)
status_code = 500 status_code = 500
try: try:
response = await call_next(request) try:
except Exception: response = await call_next(request)
except Exception:
if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000)
key = f"{request.method} {normalized_path} 5xx"
setup_config_stats[key] += 1
logger.exception(
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
500,
duration_ms,
)
if should_security_log:
_enqueue_security_access_log(request, path, status_code, started_at)
raise
status_code = int(response.status_code)
if audit_context.request_id:
response.headers["X-Request-ID"] = audit_context.request_id
if is_setup_config_path: if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path) normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000) duration_ms = int((time.perf_counter() - started_at) * 1000)
key = f"{request.method} {normalized_path} 5xx" status_bucket = f"{status_code // 100}xx"
key = f"{request.method} {normalized_path} {status_bucket}"
setup_config_stats[key] += 1 setup_config_stats[key] += 1
logger.exception( if status_code >= 500:
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s", logger.error(
request.method, "setup_config_request method=%s path=%s status=%s duration_ms=%s",
normalized_path, request.method,
500, normalized_path,
duration_ms, status_code,
) duration_ms,
)
elif status_code >= 400:
logger.warning(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
else:
logger.info(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
if should_security_log: if should_security_log:
_enqueue_security_access_log(request, path, status_code, started_at) _enqueue_security_access_log(request, path, status_code, started_at)
raise return response
finally:
status_code = int(response.status_code) reset_request_audit_context(audit_context_token)
if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000)
status_bucket = f"{status_code // 100}xx"
key = f"{request.method} {normalized_path} {status_bucket}"
setup_config_stats[key] += 1
if status_code >= 500:
logger.error(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
elif status_code >= 400:
logger.warning(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
else:
logger.info(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
if should_security_log:
_enqueue_security_access_log(request, path, status_code, started_at)
return response
register_exception_handlers(app) register_exception_handlers(app)
@@ -209,6 +257,36 @@ def create_app() -> FastAPI:
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get(
"/readyz",
tags=["health"],
summary="服务就绪检查",
description="验证应用可访问数据库;失败时返回 503。",
)
async def readiness(db=Depends(get_db_session)):
started_at = time.perf_counter()
try:
await db.execute(text("SELECT 1"))
except Exception as exc:
logger.exception("Readiness database check failed")
return JSONResponse(
status_code=503,
content={
"status": "not_ready",
"database": {
"status": "unhealthy",
"error_type": type(exc).__name__,
},
},
)
return {
"status": "ready",
"database": {
"status": "healthy",
"latency_ms": round((time.perf_counter() - started_at) * 1000, 2),
},
}
@app.get( @app.get(
"/health/setup-config-stats", "/health/setup-config-stats",
tags=["health"], tags=["health"],
@@ -225,22 +303,14 @@ def create_app() -> FastAPI:
return {"totals": totals, "by_endpoint": summary} return {"totals": totals, "by_endpoint": summary}
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")
app.include_router(onlyoffice_internal_router)
app.include_router(collaboration_internal_router)
return app return app
app = create_app() app = create_app()
def _resolve_client_ip(request) -> str | None:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else None
def _resolve_auth_context(request) -> tuple[str, str | None]: def _resolve_auth_context(request) -> tuple[str, str | None]:
authorization = request.headers.get("authorization") or "" authorization = request.headers.get("authorization") or ""
if not authorization.lower().startswith("bearer "): if not authorization.lower().startswith("bearer "):
@@ -261,14 +331,23 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
if not writer: if not writer:
return return
auth_status, user_identifier = _resolve_auth_context(request) auth_status, user_identifier = _resolve_auth_context(request)
context = get_request_audit_context()
writer.enqueue( writer.enqueue(
{ {
"method": request.method, "method": request.method,
"path": path, "path": path,
"status_code": status_code, "status_code": status_code,
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2), "elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
"client_ip": _resolve_client_ip(request), "client_ip": resolve_client_ip(request),
"user_agent": request.headers.get("user-agent"), "user_agent": request.headers.get("user-agent"),
"client_type": resolve_ctms_client_type(request.headers),
"client_version": request.headers.get("x-ctms-client-version"),
"client_platform": request.headers.get("x-ctms-client-platform"),
"build_channel": request.headers.get("x-ctms-build-channel"),
"build_commit": request.headers.get("x-ctms-build-commit"),
"request_headers": context.request_headers if context else build_sanitized_request_headers(request),
"request_snapshot": context.request_snapshot if context else build_request_snapshot(request),
"request_id": context.request_id if context else None,
"auth_status": auth_status, "auth_status": auth_status,
"user_identifier": user_identifier, "user_identifier": user_identifier,
} }
+13 -1
View File
@@ -13,7 +13,12 @@ from app.db.base_class import Base
class AuditLog(Base): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
__table_args__ = (Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),) __table_args__ = (
Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),
Index("ix_audit_logs_operator_created", "operator_id", "created_at"),
Index("ix_audit_logs_client_ip_created", "client_ip", "created_at"),
Index("ix_audit_logs_client_source_created", "client_type", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True) study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
@@ -23,4 +28,11 @@ class AuditLog(Base):
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True) detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
operator_role: Mapped[str] = mapped_column(String(50), nullable=False) operator_role: Mapped[str] = mapped_column(String(50), nullable=False)
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class CollaborationFolder(Base):
__tablename__ = "collaboration_folders"
__table_args__ = (
Index("ix_collaboration_folders_study_parent", "study_id", "parent_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationFile(Base):
__tablename__ = "collaboration_files"
__table_args__ = (
Index("ix_collaboration_files_study_folder", "study_id", "folder_id"),
Index("ix_collaboration_files_study_status", "study_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
file_type: Mapped[str] = mapped_column(String(16), nullable=False)
extension: Mapped[str] = mapped_column(String(16), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE")
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
current_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
)
generation: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
allow_export: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
allow_edit_request: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
allow_sheet_structure_edit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
sheet_structure_protection_backup: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationMember(Base):
__tablename__ = "collaboration_members"
__table_args__ = (
UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"),
Index("ix_collaboration_members_user", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False)
invited_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationEditRequest(Base):
__tablename__ = "collaboration_edit_requests"
__table_args__ = (
Index("ix_collaboration_edit_requests_file_status", "file_id", "status"),
Index(
"uq_collaboration_edit_requests_pending_user",
"file_id",
"requester_id",
unique=True,
postgresql_where=text("status = 'PENDING'"),
),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
requester_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="PENDING", server_default="PENDING")
resolved_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationShareLink(Base):
__tablename__ = "collaboration_share_links"
__table_args__ = (
UniqueConstraint("file_id", name="uq_collaboration_share_link_file"),
Index("ix_collaboration_share_links_enabled_expiry", "enabled", "expires_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
access_mode: Mapped[str] = mapped_column(String(12), nullable=False, default="VIEW", server_default="VIEW")
expiry_policy: Mapped[str] = mapped_column(
String(16), nullable=False, default="SEVEN_DAYS", server_default="SEVEN_DAYS"
)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
token_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
failed_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
last_failed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
updated_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationRevision(Base):
__tablename__ = "collaboration_revisions"
__table_args__ = (
UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"),
Index("ix_collaboration_revisions_file_created", "file_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
revision_no: Mapped[int] = mapped_column(Integer, nullable=False)
parent_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
)
file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
source: Mapped[str] = mapped_column(String(24), nullable=False)
change_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
class CollaborationSession(Base):
__tablename__ = "collaboration_sessions"
__table_args__ = (
UniqueConstraint("document_key", name="uq_collaboration_session_document_key"),
Index("ix_collaboration_sessions_file_status", "file_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
base_revision_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id"), nullable=False
)
document_key: Mapped[str] = mapped_column(String(128), nullable=False)
generation: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE")
started_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
active_users: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
last_callback_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class CollaborationCallbackReceipt(Base):
__tablename__ = "collaboration_callback_receipts"
__table_args__ = (
UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False
)
fingerprint: Mapped[str] = mapped_column(String(128), nullable=False)
callback_status: Mapped[int] = mapped_column(Integer, nullable=False)
result: Mapped[str] = mapped_column(String(24), nullable=False)
revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
@@ -0,0 +1,48 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class DesktopNotificationSubscription(Base):
__tablename__ = "desktop_notification_subscriptions"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
enabled_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())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
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"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.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)
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+1
View File
@@ -47,6 +47,7 @@ class DocumentVersion(Base):
effective_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) effective_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
superseded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) superseded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
file_uri: Mapped[str] = mapped_column(String(500), nullable=False) file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
original_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
file_hash: Mapped[str] = mapped_column(String(128), nullable=False) file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class SmtpSecurity(str, enum.Enum):
NONE = "NONE"
SSL = "SSL"
STARTTLS = "STARTTLS"
class EmailVerificationPurpose(str, enum.Enum):
REGISTER = "REGISTER"
PASSWORD_RESET = "PASSWORD_RESET"
PASSWORD_RESET_LINK = "PASSWORD_RESET_LINK"
class SystemEmailSettings(Base):
__tablename__ = "system_email_settings"
__table_args__ = (UniqueConstraint("register_domain", name="uq_system_email_settings_register_domain"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
register_domain: Mapped[str] = mapped_column(String(255), nullable=False, default="huapont.cn")
smtp_host: Mapped[str] = mapped_column(String(255), nullable=False, default="")
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False)
smtp_security: Mapped[SmtpSecurity] = mapped_column(
Enum(SmtpSecurity, name="smtp_security"),
nullable=False,
default=SmtpSecurity.SSL,
server_default=SmtpSecurity.SSL.value,
)
smtp_username: Mapped[str] = mapped_column(String(255), nullable=False, default="")
smtp_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sender_email: Mapped[str] = mapped_column(String(255), nullable=False, default="")
sender_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
allowed_register_domain: Mapped[str] = mapped_column(String(255), nullable=False, default="huapont.cn")
verification_code_ttl_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
send_cooldown_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
max_verify_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
updated_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class EmailVerificationCode(Base):
__tablename__ = "email_verification_codes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
purpose: Mapped[EmailVerificationPurpose] = mapped_column(
Enum(EmailVerificationPurpose, name="email_verification_purpose"),
nullable=False,
default=EmailVerificationPurpose.REGISTER,
server_default=EmailVerificationPurpose.REGISTER.value,
)
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Notification(Base):
__tablename__ = "notifications"
__table_args__ = (
UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"),
Index(
"ix_notifications_recipient_study_state",
"recipient_id",
"study_id",
"resolved_at",
"read_at",
"created_at",
),
Index("ix_notifications_source", "source_type", "source_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("studies.id", ondelete="CASCADE"), nullable=False
)
recipient_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
category: Mapped[str] = mapped_column(String(64), nullable=False)
priority: Mapped[str] = mapped_column(String(16), nullable=False, default="NORMAL", server_default="NORMAL")
title: Mapped[str] = mapped_column(String(180), nullable=False)
message: Mapped[str] = mapped_column(String(500), nullable=False)
action_path: Mapped[str | None] = mapped_column(Text, nullable=True)
source_type: Mapped[str] = mapped_column(String(64), nullable=False)
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())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+16 -2
View File
@@ -6,13 +6,15 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, String from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Index, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base_class import Base from app.db.base_class import Base
JSONB_TYPE = JSON().with_variant(JSONB, "postgresql")
class PermissionAccessLog(Base): class PermissionAccessLog(Base):
__tablename__ = "permission_access_logs" __tablename__ = "permission_access_logs"
@@ -22,6 +24,9 @@ class PermissionAccessLog(Base):
Index("ix_perm_log_endpoint_created", "endpoint_key", "created_at"), Index("ix_perm_log_endpoint_created", "endpoint_key", "created_at"),
Index("ix_perm_log_created_at", "created_at"), Index("ix_perm_log_created_at", "created_at"),
Index("ix_perm_log_allowed", "allowed", "created_at"), Index("ix_perm_log_allowed", "allowed", "created_at"),
Index("ix_perm_log_ip_created", "ip_address", "created_at"),
Index("ix_perm_log_client_source_created", "client_type", "created_at"),
Index("ix_perm_log_request_id", "request_id"),
) )
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
@@ -38,6 +43,15 @@ class PermissionAccessLog(Base):
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False) allowed: Mapped[bool] = mapped_column(Boolean, nullable=False)
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False) elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
request_headers: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
request_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
request_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
+17 -2
View File
@@ -6,13 +6,15 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import DateTime, Float, Index, Integer, String from sqlalchemy import JSON, DateTime, Float, Index, Integer, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base_class import Base from app.db.base_class import Base
JSONB_TYPE = JSON().with_variant(JSONB, "postgresql")
class SecurityAccessLog(Base): class SecurityAccessLog(Base):
__tablename__ = "security_access_logs" __tablename__ = "security_access_logs"
@@ -21,6 +23,9 @@ class SecurityAccessLog(Base):
Index("ix_security_log_ip_created", "client_ip", "created_at"), Index("ix_security_log_ip_created", "client_ip", "created_at"),
Index("ix_security_log_status_created", "status_code", "created_at"), Index("ix_security_log_status_created", "status_code", "created_at"),
Index("ix_security_log_auth_created", "auth_status", "created_at"), Index("ix_security_log_auth_created", "auth_status", "created_at"),
Index("ix_security_log_request_id", "request_id"),
Index("ix_security_log_category_created", "category", "created_at"),
Index("ix_security_log_severity_created", "severity", "created_at"),
) )
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -30,8 +35,18 @@ class SecurityAccessLog(Base):
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False) elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
request_headers: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
request_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
auth_status: Mapped[str] = mapped_column(String(30), nullable=False) auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True) user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
request_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
severity: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
@@ -0,0 +1,48 @@
"""Hourly source-location rollup for monitoring analytics."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.base_class import Base
class SourceLocationSnapshot(Base):
__tablename__ = "source_location_snapshots"
__table_args__ = (
UniqueConstraint("bucket_time", "ip_hash", "user_hash", name="uq_source_location_bucket_identity"),
Index("ix_source_location_bucket", "bucket_time"),
Index("ix_source_location_country_bucket", "country_code", "bucket_time"),
Index("ix_source_location_risk_bucket", "high_risk_count", "bucket_time"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
bucket_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ip_hash: Mapped[str] = mapped_column(String(64), nullable=False)
user_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
country: Mapped[str] = mapped_column(String(100), nullable=False, default="")
country_code: Mapped[str] = mapped_column(String(16), nullable=False, default="")
province: Mapped[str] = mapped_column(String(100), nullable=False, default="")
region_code: Mapped[str] = mapped_column(String(24), nullable=False, default="")
city: Mapped[str] = mapped_column(String(100), nullable=False, default="")
isp: Mapped[str] = mapped_column(String(160), nullable=False, default="")
location: Mapped[str] = mapped_column(String(320), nullable=False, default="")
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
accuracy_level: Mapped[str] = mapped_column(String(16), nullable=False, default="unknown")
allowed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
denied_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
security_event_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
high_risk_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
auth_failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class UserLoginSession(Base):
"""Server-side login activity record without storing credentials."""
__tablename__ = "user_login_sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
client_type: Mapped[str] = mapped_column(String(16), nullable=False, default="web")
client_platform: Mapped[str | None] = mapped_column(String(32), nullable=True)
client_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
client_source: Mapped[str | None] = mapped_column(String(32), nullable=True)
login_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
end_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
+17
View File
@@ -7,10 +7,27 @@ from pydantic import BaseModel, ConfigDict
class AuditLogRead(BaseModel): class AuditLogRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: Optional[uuid.UUID] = None
entity_type: str
entity_id: Optional[uuid.UUID] = None
action: str action: str
detail: Optional[str] detail: Optional[str]
operator_id: uuid.UUID operator_id: uuid.UUID
operator_name: Optional[str] = None
operator_email: Optional[str] = None
operator_role: str operator_role: str
client_ip: Optional[str] = None
ip_location: str = ""
ip_country: str = ""
ip_province: str = ""
ip_city: str = ""
ip_isp: str = ""
user_agent: Optional[str] = None
client_type: Optional[str] = None
client_version: Optional[str] = None
client_platform: Optional[str] = None
build_channel: Optional[str] = None
build_commit: Optional[str] = None
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+285
View File
@@ -0,0 +1,285 @@
import uuid
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
CollaborationFileType = Literal["word", "cell", "slide"]
CollaborationMemberRole = Literal["EDITOR", "MANAGER"]
CollaborationFileStatus = Literal["ACTIVE", "ARCHIVED", "DELETED"]
CollaborationShareAccessMode = Literal["VIEW", "EDIT"]
CollaborationShareExpiryPolicy = Literal["ONE_DAY", "SEVEN_DAYS", "THIRTY_DAYS", "PERMANENT"]
CollaborationEditRequestStatus = Literal["PENDING", "APPROVED", "REJECTED"]
class CollaborationFolderCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
parent_id: Optional[uuid.UUID] = None
sort_order: int = 0
@field_validator("name")
@classmethod
def normalize_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("文件夹名称不能为空")
return value
class CollaborationFolderUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=120)
parent_id: Optional[uuid.UUID] = None
sort_order: Optional[int] = None
class CollaborationFolderRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
parent_id: Optional[uuid.UUID]
name: str
sort_order: int
created_by: uuid.UUID
deleted_at: Optional[datetime]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class CollaborationFileCreate(BaseModel):
title: str = Field(min_length=1, max_length=240)
file_type: CollaborationFileType
folder_id: Optional[uuid.UUID] = None
class CollaborationFileUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=240)
folder_id: Optional[uuid.UUID] = None
status: Optional[Literal["ACTIVE", "ARCHIVED"]] = None
allow_export: Optional[bool] = None
allow_edit_request: Optional[bool] = None
allow_sheet_structure_edit: Optional[bool] = None
class CollaborationRevisionRead(BaseModel):
id: uuid.UUID
file_id: uuid.UUID
revision_no: int
parent_revision_id: Optional[uuid.UUID]
original_filename: str
file_hash: str
file_size: int
mime_type: str
source: str
change_summary: Optional[str]
created_by: Optional[uuid.UUID]
created_by_name: Optional[str] = None
created_by_avatar_url: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class CollaborationRevisionUpdate(BaseModel):
change_summary: str = Field(min_length=1, max_length=240)
@field_validator("change_summary")
@classmethod
def normalize_change_summary(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("版本名称不能为空")
return value
class CollaborationRevisionCopyRequest(BaseModel):
title: str = Field(min_length=1, max_length=240)
folder_id: Optional[uuid.UUID] = None
@field_validator("title")
@classmethod
def normalize_title(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("文件名不能为空")
return value
class CollaborationFileCollaboratorRead(BaseModel):
user_id: uuid.UUID
full_name: str
role: CollaborationMemberRole
avatar_url: Optional[str] = None
class CollaborationFileRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
folder_id: Optional[uuid.UUID]
title: str
file_type: CollaborationFileType
extension: str
status: CollaborationFileStatus
owner_id: uuid.UUID
current_revision_id: Optional[uuid.UUID]
generation: int
allow_export: bool = False
allow_edit_request: bool = False
allow_sheet_structure_edit: bool = True
deleted_at: Optional[datetime]
created_at: datetime
updated_at: datetime
owner_name: Optional[str] = None
folder_name: Optional[str] = None
current_revision_no: Optional[int] = None
current_revision_file_size: Optional[int] = None
current_revision_mime_type: Optional[str] = None
current_revision_created_at: Optional[datetime] = None
collaboration_role: Optional[CollaborationMemberRole] = None
collaborators: list[CollaborationFileCollaboratorRead] = Field(default_factory=list)
can_edit: bool = False
can_manage: bool = False
can_export: bool = False
can_request_edit: bool = False
edit_request_status: Optional[CollaborationEditRequestStatus] = None
can_transfer_ownership: bool = False
model_config = ConfigDict(from_attributes=True)
class CollaborationMemberUpsert(BaseModel):
user_id: uuid.UUID
role: CollaborationMemberRole
class CollaborationMemberRead(BaseModel):
id: uuid.UUID
file_id: uuid.UUID
user_id: uuid.UUID
role: CollaborationMemberRole
invited_by: uuid.UUID
full_name: str
email: str
created_at: datetime
class CollaborationEditRequestRead(BaseModel):
id: uuid.UUID
file_id: uuid.UUID
requester_id: uuid.UUID
requester_name: str
requester_email: str
status: CollaborationEditRequestStatus
resolved_by: Optional[uuid.UUID]
resolved_at: Optional[datetime]
created_at: datetime
class CollaborationEditRequestResolve(BaseModel):
status: Literal["APPROVED", "REJECTED"]
class CollaborationOwnershipTransferRequest(BaseModel):
new_owner_id: uuid.UUID
class CollaborationShareLinkUpdate(BaseModel):
enabled: bool
access_mode: CollaborationShareAccessMode = "VIEW"
expiry_policy: CollaborationShareExpiryPolicy = "SEVEN_DAYS"
password_mode: Literal["KEEP", "SET", "CLEAR"] = "KEEP"
password: Optional[str] = Field(default=None, min_length=4, max_length=64)
@model_validator(mode="after")
def validate_password_change(self):
if self.password_mode == "SET" and not self.password:
raise ValueError("设置链接密码时必须提供密码")
if self.password_mode != "SET" and self.password is not None:
raise ValueError("仅在设置链接密码时允许提交密码")
return self
class CollaborationShareLinkRead(BaseModel):
id: uuid.UUID
file_id: uuid.UUID
enabled: bool
access_mode: CollaborationShareAccessMode
expiry_policy: CollaborationShareExpiryPolicy
expires_at: Optional[datetime]
has_password: bool
share_path: str = "/collaboration/share"
share_token: Optional[str] = None
created_at: datetime
updated_at: datetime
class CollaborationPublicShareMetadata(BaseModel):
file_name: str
file_type: CollaborationFileType
access_mode: Literal["view", "edit"]
allow_export: bool
requires_password: bool
expires_at: Optional[datetime]
class CollaborationSharePasswordRequest(BaseModel):
password: str = Field(min_length=1, max_length=64)
class CollaborationShareAccessGrant(BaseModel):
access_token: str
expires_at: datetime
class CollaborationPublicEditorConfigRequest(BaseModel):
access_token: Optional[str] = Field(default=None, max_length=2048)
client_id: str = Field(min_length=8, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
display_name: str = Field(default="链接访客", min_length=1, max_length=40)
@field_validator("display_name")
@classmethod
def normalize_display_name(cls, value: str) -> str:
return value.strip() or "链接访客"
class CollaborationCandidateRead(BaseModel):
user_id: uuid.UUID
full_name: str
email: str
role_in_study: str
can_be_editor: bool
can_be_manager: bool
class CollaborationEditorConfigRead(BaseModel):
file_id: uuid.UUID
file_name: str
access_mode: Literal["view", "edit"]
can_save_as: bool = False
can_download: bool = False
can_request_edit: bool = False
host_path: str = "/onlyoffice-host.html"
expires_at: datetime
config: dict
class CollaborationExportRecord(BaseModel):
file_type: str = Field(min_length=1, max_length=16, pattern=r"^[a-z0-9]+$")
class CollaborationCallbackPayload(BaseModel):
key: str
status: int
url: Optional[str] = None
changesurl: Optional[str] = None
filetype: Optional[str] = None
forcesavetype: Optional[int] = None
userdata: Optional[str] = None
users: list[str] = Field(default_factory=list)
actions: list[dict] = Field(default_factory=list)
history: Optional[dict] = None
class CollaborationRestoreRequest(BaseModel):
change_summary: Optional[str] = Field(default=None, max_length=500)
+1
View File
@@ -36,6 +36,7 @@ class DocumentVersionRead(BaseModel):
effective_at: Optional[datetime] = None effective_at: Optional[datetime] = None
superseded_at: Optional[datetime] = None superseded_at: Optional[datetime] = None
file_uri: str file_uri: str
original_filename: Optional[str] = None
file_hash: str file_hash: str
file_size: int file_size: int
mime_type: Optional[str] = None mime_type: Optional[str] = None
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, EmailStr, Field, field_validator
SmtpSecurityValue = Literal["NONE", "SSL", "STARTTLS"]
class EmailSettingsRead(BaseModel):
register_domain: str = "huapont.cn"
smtp_host: str = ""
smtp_port: int = 465
smtp_security: SmtpSecurityValue = "SSL"
smtp_username: str = ""
smtp_password_configured: bool = False
sender_email: str = ""
sender_name: Optional[str] = "CTMS 系统"
allowed_register_domain: str = "huapont.cn,qq.com"
verification_code_ttl_minutes: int = 10
send_cooldown_seconds: int = 60
max_verify_attempts: int = 5
updated_at: Optional[datetime] = None
class EmailSettingsUpdate(BaseModel):
smtp_host: str = Field(default="", max_length=255)
smtp_port: int = Field(ge=1, le=65535)
smtp_security: SmtpSecurityValue = "SSL"
smtp_username: str = Field(default="", max_length=255)
smtp_password: Optional[str] = Field(default=None, max_length=500)
sender_email: str = Field(default="", max_length=255)
sender_name: Optional[str] = Field(default="CTMS 系统", max_length=255)
allowed_register_domain: str = Field(default="", max_length=255)
verification_code_ttl_minutes: int = Field(default=10, ge=1, le=60)
send_cooldown_seconds: int = Field(default=60, ge=10, le=3600)
max_verify_attempts: int = Field(default=5, ge=1, le=20)
@field_validator("allowed_register_domain")
@classmethod
def normalize_domain(cls, value: str) -> str:
domains = []
for item in value.replace("", ",").split(","):
domain = item.strip().lower().lstrip("@")
if domain and domain not in domains:
domains.append(domain)
if not domains:
raise ValueError("至少配置一个允许注册邮箱域名")
return ",".join(domains)
class EmailTestRequest(BaseModel):
recipient_email: EmailStr
class EmailDomainCreateRequest(BaseModel):
register_domain: str = Field(min_length=1, max_length=255)
@field_validator("register_domain")
@classmethod
def normalize_domain(cls, value: str) -> str:
return value.strip().lower().lstrip("@")
class EmailSettingsListResponse(BaseModel):
items: list[EmailSettingsRead]
total: int
class RegisterEmailCodeSendRequest(BaseModel):
email: EmailStr
class RegisterEmailCodeVerifyRequest(BaseModel):
email: EmailStr
code: str = Field(min_length=4, max_length=12)
class PasswordResetLinkSendRequest(BaseModel):
email: EmailStr
class PasswordResetRequest(BaseModel):
email: EmailStr
code: str = Field(min_length=4, max_length=12)
password: str = Field(min_length=8, max_length=72)
@field_validator("password")
@classmethod
def validate_password_strength(cls, value: str) -> str:
has_letter = any(ch.isalpha() for ch in value)
has_number = any(ch.isdigit() for ch in value)
if not has_letter or not has_number:
raise ValueError("密码需至少 8 位且包含字母和数字")
return value
class PasswordResetCodeVerifyRequest(BaseModel):
email: EmailStr
code: str = Field(min_length=4, max_length=12)
class PasswordResetTokenRequest(BaseModel):
token: str = Field(min_length=20, max_length=200)
password: str = Field(min_length=8, max_length=72)
@field_validator("password")
@classmethod
def validate_password_strength(cls, value: str) -> str:
has_letter = any(ch.isalpha() for ch in value)
has_number = any(ch.isdigit() for ch in value)
if not has_letter or not has_number:
raise ValueError("密码需至少 8 位且包含字母和数字")
return value
class EmailCodeResponse(BaseModel):
message: str
class EmailCodeVerifyResponse(BaseModel):
verified: bool
class PasswordResetCodeVerifyResponse(BaseModel):
verified: bool
reset_token: str
+54
View File
@@ -16,5 +16,59 @@ class NotificationItem(BaseModel):
change_summary: str | None = None change_summary: str | None = None
effective_at: datetime | None = None effective_at: datetime | None = None
created_at: datetime created_at: datetime
study_id: uuid.UUID | None = None
study_name: str | None = None
delivered_at: datetime | None = None
read_at: datetime | None = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class DesktopNotificationSubscriptionRead(BaseModel):
enabled: bool
enabled_at: datetime | None = None
class DesktopNotificationSubscriptionUpdate(BaseModel):
enabled: bool
class DesktopNotificationClaimRequest(BaseModel):
limit: int = 20
class GeneralNotificationRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
recipient_id: uuid.UUID
category: str
priority: str
title: str
message: str
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]
+14
View File
@@ -0,0 +1,14 @@
from datetime import datetime
from typing import Any, Literal
import uuid
from pydantic import BaseModel
class OnlyOfficePreviewConfigRead(BaseModel):
resource_type: Literal["attachment", "version", "collaboration_revision"]
resource_id: uuid.UUID
file_name: str
host_path: str = "/onlyoffice-host.html"
expires_at: datetime
config: dict[str, Any]
+20 -6
View File
@@ -6,6 +6,7 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"] UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
LoginStatus = Literal["ONLINE", "OFFLINE"]
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$") PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
@@ -60,6 +61,11 @@ class UserRead(BaseModel):
approved_at: Optional[datetime] = None approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None approved_by: Optional[uuid.UUID] = None
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
login_status: LoginStatus = "OFFLINE"
last_login_at: Optional[datetime] = None
last_seen_at: Optional[datetime] = None
last_client_type: Optional[Literal["web", "desktop"]] = None
active_session_count: int = 0
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -78,13 +84,21 @@ class UserResponse(UserRead):
pass pass
class UserAdminReviewRequest(BaseModel): class UserLoginActivityRead(BaseModel):
action: Literal["approve", "reject"] id: uuid.UUID
client_type: Literal["web", "desktop"]
client_platform: Optional[str] = None
client_version: Optional[str] = None
client_source: Optional[str] = None
login_ip: Optional[str] = None
ip_location: Optional[str] = None
login_at: datetime
last_seen_at: datetime
ended_at: Optional[datetime] = None
end_reason: Optional[str] = None
activity_status: Literal["ONLINE", "OFFLINE", "ENDED"]
model_config = ConfigDict(from_attributes=True)
class AdminUserListResponse(BaseModel):
items: list[UserResponse]
total: int
class UserSelfUpdate(_PasswordValidator): class UserSelfUpdate(_PasswordValidator):
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,289 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import uuid
from datetime import datetime, timedelta, timezone
from anyio import to_thread
from fastapi import HTTPException, status
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import hash_password, verify_password
from app.models.collaboration import CollaborationFile, CollaborationShareLink
from app.schemas.collaboration import (
CollaborationPublicShareMetadata,
CollaborationShareAccessGrant,
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
)
from app.services import collaboration_service
SHARE_PATH = "/collaboration/share"
SHARE_ACCESS_TTL_SECONDS = 30 * 60
PASSWORD_FAILURE_WINDOW = timedelta(minutes=15)
PASSWORD_LOCK_DURATION = timedelta(minutes=15)
PASSWORD_FAILURE_LIMIT = 5
_ACCESS_PURPOSE = "ctms-collaboration-share-access"
_EXPIRY_DURATIONS = {
"ONE_DAY": timedelta(days=1),
"SEVEN_DAYS": timedelta(days=7),
"THIRTY_DAYS": timedelta(days=30),
"PERMANENT": None,
}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _signing_key() -> bytes:
return hmac.new(
settings.JWT_SECRET_KEY.encode("utf-8"),
b"ctms-collaboration-share-v1",
hashlib.sha256,
).digest()
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _b64decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii"))
def share_token(link: CollaborationShareLink) -> str:
payload = f"{link.id}.{link.token_version}".encode("ascii")
signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
return f"{_b64encode(payload)}.{_b64encode(signature)}"
def _decode_share_token(value: str | None) -> tuple[uuid.UUID, int]:
token = (value or "").strip()
if not token or len(token) > 256 or token.count(".") != 1:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
encoded_payload, encoded_signature = token.split(".", 1)
try:
payload = _b64decode(encoded_payload)
actual_signature = _b64decode(encoded_signature)
if (
not hmac.compare_digest(_b64encode(payload), encoded_payload)
or not hmac.compare_digest(_b64encode(actual_signature), encoded_signature)
):
raise ValueError("non-canonical token encoding")
expected_signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
if not hmac.compare_digest(actual_signature, expected_signature):
raise ValueError("signature mismatch")
raw_id, raw_version = payload.decode("ascii").split(".", 1)
return uuid.UUID(raw_id), int(raw_version)
except (ValueError, UnicodeError, TypeError, binascii.Error) as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") from exc
def _expiry_for_policy(policy: str, now: datetime) -> datetime | None:
duration = _EXPIRY_DURATIONS[policy]
return now + duration if duration else None
def _is_expired(link: CollaborationShareLink, now: datetime | None = None) -> bool:
return bool(link.expires_at and link.expires_at <= (now or _now()))
async def _link_for_file(
db: AsyncSession,
item: CollaborationFile,
user,
*,
create: bool,
lock: bool = False,
) -> CollaborationShareLink | None:
await collaboration_service.require_file_manager(db, item, user)
statement = select(CollaborationShareLink).where(CollaborationShareLink.file_id == item.id)
if lock:
statement = statement.with_for_update()
link = await db.scalar(statement)
if link or not create:
return link
now = _now()
link = CollaborationShareLink(
file_id=item.id,
enabled=False,
access_mode="VIEW",
expiry_policy="SEVEN_DAYS",
expires_at=now + timedelta(days=7),
created_by=user.id,
updated_by=user.id,
)
db.add(link)
await db.flush()
return link
def share_link_read(link: CollaborationShareLink) -> CollaborationShareLinkRead:
return CollaborationShareLinkRead(
id=link.id,
file_id=link.file_id,
enabled=link.enabled,
access_mode=link.access_mode,
expiry_policy=link.expiry_policy,
expires_at=link.expires_at,
has_password=bool(link.password_hash),
share_path=SHARE_PATH,
share_token=share_token(link) if link.enabled else None,
created_at=link.created_at,
updated_at=link.updated_at,
)
async def get_share_link(
db: AsyncSession, item: CollaborationFile, user
) -> CollaborationShareLinkRead:
link = await _link_for_file(db, item, user, create=True)
assert link is not None
await db.commit()
await db.refresh(link)
return share_link_read(link)
async def update_share_link(
db: AsyncSession,
item: CollaborationFile,
payload: CollaborationShareLinkUpdate,
user,
) -> CollaborationShareLinkRead:
link = await _link_for_file(db, item, user, create=True, lock=True)
assert link is not None
now = _now()
link.enabled = payload.enabled
link.access_mode = payload.access_mode
link.expiry_policy = payload.expiry_policy
link.expires_at = _expiry_for_policy(payload.expiry_policy, now)
link.updated_by = user.id
if payload.password_mode == "SET":
link.password_hash = await to_thread.run_sync(hash_password, payload.password or "")
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
elif payload.password_mode == "CLEAR":
link.password_hash = None
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
await db.commit()
await db.refresh(link)
return share_link_read(link)
async def resolve_active_share(
db: AsyncSession,
token: str | None,
*,
lock: bool = False,
) -> tuple[CollaborationShareLink, CollaborationFile]:
link_id, version = _decode_share_token(token)
statement = select(CollaborationShareLink).where(CollaborationShareLink.id == link_id)
if lock:
statement = statement.with_for_update()
link = await db.scalar(statement)
if not link or link.token_version != version or not link.enabled:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
if _is_expired(link):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="共享链接已过期")
item = await db.get(CollaborationFile, link.file_id)
if not item or item.deleted_at or item.status != "ACTIVE":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件不存在或已停止共享")
return link, item
async def public_metadata(
db: AsyncSession, token: str | None
) -> CollaborationPublicShareMetadata:
link, item = await resolve_active_share(db, token)
return CollaborationPublicShareMetadata(
file_name=item.title,
file_type=item.file_type,
access_mode="edit" if link.access_mode == "EDIT" else "view",
allow_export=item.allow_export,
requires_password=bool(link.password_hash),
expires_at=link.expires_at,
)
def _grant_token(link: CollaborationShareLink) -> CollaborationShareAccessGrant:
now = _now()
expires_at = now + timedelta(seconds=SHARE_ACCESS_TTL_SECONDS)
if link.expires_at and link.expires_at < expires_at:
expires_at = link.expires_at
value = jwt.encode(
{
"purpose": _ACCESS_PURPOSE,
"sub": str(link.id),
"ver": link.token_version,
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp()),
},
_signing_key().hex(),
algorithm="HS256",
)
return CollaborationShareAccessGrant(access_token=value, expires_at=expires_at)
async def verify_share_password(
db: AsyncSession,
token: str | None,
password: str,
) -> CollaborationShareAccessGrant:
link, _ = await resolve_active_share(db, token, lock=True)
if not link.password_hash:
return _grant_token(link)
now = _now()
if link.locked_until and link.locked_until > now:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="密码尝试次数过多,请稍后再试",
)
if link.last_failed_at and now - link.last_failed_at > PASSWORD_FAILURE_WINDOW:
link.failed_attempts = 0
valid = await to_thread.run_sync(verify_password, password, link.password_hash)
if not valid:
link.failed_attempts += 1
link.last_failed_at = now
if link.failed_attempts >= PASSWORD_FAILURE_LIMIT:
link.locked_until = now + PASSWORD_LOCK_DURATION
await db.commit()
if link.locked_until:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="密码尝试次数过多,请稍后再试",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接密码不正确")
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
await db.commit()
return _grant_token(link)
def validate_access_grant(link: CollaborationShareLink, value: str | None) -> None:
if not link.password_hash:
return
if not value:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请输入链接密码")
try:
payload = jwt.decode(value, _signing_key().hex(), algorithms=["HS256"])
if (
payload.get("purpose") != _ACCESS_PURPOSE
or not hmac.compare_digest(str(payload.get("sub") or ""), str(link.id))
or payload.get("ver") != link.token_version
):
raise JWTError("share grant mismatch")
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接访问凭证已失效") from exc
@@ -0,0 +1,170 @@
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, exists, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.desktop_notification import (
DesktopNotificationDelivery,
DesktopNotificationSubscription,
)
from app.models.notification import Notification
from app.models.study_member import StudyMember
from app.models.user import User
CLAIM_LEASE = timedelta(minutes=5)
async def get_subscription(db: AsyncSession, user_id: uuid.UUID) -> DesktopNotificationSubscription | None:
return await db.get(DesktopNotificationSubscription, user_id)
async def set_subscription(
db: AsyncSession,
user_id: uuid.UUID,
enabled: bool,
) -> DesktopNotificationSubscription:
subscription = await get_subscription(db, user_id)
now = datetime.now(timezone.utc)
if subscription is None:
subscription = DesktopNotificationSubscription(
user_id=user_id,
enabled=enabled,
enabled_at=now if enabled else None,
)
db.add(subscription)
elif subscription.enabled != enabled:
subscription.enabled = enabled
subscription.enabled_at = now if enabled else None
await db.commit()
await db.refresh(subscription)
return subscription
def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime):
active_membership = exists(
select(StudyMember.id).where(
StudyMember.study_id == Notification.study_id,
StudyMember.user_id == user_id,
StudyMember.is_active.is_(True),
)
)
return (
select(Notification, DesktopNotificationDelivery)
.outerjoin(
DesktopNotificationDelivery,
and_(
DesktopNotificationDelivery.notification_id == Notification.id,
DesktopNotificationDelivery.user_id == user_id,
),
)
.where(
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_(
DesktopNotificationDelivery.delivered_at.is_(None),
or_(
DesktopNotificationDelivery.claimed_at.is_(None),
DesktopNotificationDelivery.claimed_at < lease_cutoff,
),
),
),
)
.order_by(Notification.created_at.asc())
.with_for_update(of=Notification, skip_locked=True)
)
async def claim_notifications(
db: AsyncSession,
user_id: uuid.UUID,
limit: int,
) -> 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)))
)
).all()
items: list[Notification] = []
for notification, delivery in rows:
if delivery is None:
delivery = DesktopNotificationDelivery(
user_id=user_id,
notification_id=notification.id,
)
db.add(delivery)
delivery.claim_token = token
delivery.claimed_at = now
items.append(notification)
await db.commit()
if not items:
return None, None, []
return token, now + CLAIM_LEASE, items
async def acknowledge_notifications(
db: AsyncSession,
user_id: uuid.UUID,
claim_token: uuid.UUID,
delivered_ids: list[uuid.UUID],
) -> None:
if delivered_ids:
await db.execute(
update(DesktopNotificationDelivery)
.where(
DesktopNotificationDelivery.user_id == user_id,
DesktopNotificationDelivery.claim_token == claim_token,
DesktopNotificationDelivery.notification_id.in_(delivered_ids),
)
.values(delivered_at=datetime.now(timezone.utc))
)
await db.commit()
async def mark_notification_read(
db: AsyncSession,
user_id: uuid.UUID,
notification_id: uuid.UUID,
) -> None:
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()
+67 -5
View File
@@ -6,11 +6,12 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
from urllib.parse import quote
import aiofiles import aiofiles
from fastapi import HTTPException, UploadFile, status from fastapi import HTTPException, UploadFile, status
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import 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 sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope from app.core.deps import get_cra_site_scope
@@ -30,12 +31,16 @@ from app.models.audit_log import AuditLog
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
from app.models.document import Document, DocumentScopeType, DocumentStatus from app.models.document import Document, DocumentScopeType, DocumentStatus
from app.models.document_version import DocumentVersion, DocumentVersionStatus 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.acknowledgement import AcknowledgementCreate
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
from app.schemas.notification import NotificationItem from app.schemas.notification import NotificationItem
from app.schemas.user import UserDisplay from app.schemas.user import UserDisplay
from app.services import notification_service
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents" UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents"
@@ -50,6 +55,29 @@ DOCUMENT_ACTION_PERMISSIONS = {
} }
def _safe_original_filename(value: str | None) -> str:
filename = (value or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
filename = "".join(character for character in filename if ord(character) >= 32 and ord(character) != 127)
return filename[:255] or "document"
def _content_disposition(filename: str, disposition: str = "attachment") -> str:
fallback = "".join(character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_" for character in filename)
fallback = fallback or "download"
encoded = quote(filename, safe="")
return f'{disposition}; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
def _legacy_download_filename(version: DocumentVersion, document: Document) -> str:
"""Return a readable fallback for rows created before original_filename existed."""
stored_name = Path(version.file_uri).name
suffix = Path(stored_name).suffix
title = _safe_original_filename(document.title)
if suffix and title.lower().endswith(suffix.lower()):
return title
return f"{title}{suffix}"
def _audit_detail(before: dict | None, after: dict | None) -> str: def _audit_detail(before: dict | None, after: dict | None) -> str:
payload = {"before": before, "after": after} payload = {"before": before, "after": after}
return json.dumps(payload, ensure_ascii=True) return json.dumps(payload, ensure_ascii=True)
@@ -354,7 +382,8 @@ async def create_version(
file_hash = hashlib.sha256(content).hexdigest() file_hash = hashlib.sha256(content).hexdigest()
dest_dir = UPLOAD_ROOT / str(document_id) dest_dir = UPLOAD_ROOT / str(document_id)
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}" original_filename = _safe_original_filename(file.filename)
unique_name = f"{uuid.uuid4()}{Path(original_filename).suffix}"
dest_path = dest_dir / unique_name dest_path = dest_dir / unique_name
async with aiofiles.open(dest_path, "wb") as out_file: async with aiofiles.open(dest_path, "wb") as out_file:
await out_file.write(content) await out_file.write(content)
@@ -375,6 +404,7 @@ async def create_version(
status=DocumentVersionStatus.EFFECTIVE, status=DocumentVersionStatus.EFFECTIVE,
effective_at=effective_at, effective_at=effective_at,
file_uri=str(dest_path), file_uri=str(dest_path),
original_filename=original_filename,
file_hash=file_hash, file_hash=file_hash,
file_size=len(content), file_size=len(content),
mime_type=file.content_type, mime_type=file.content_type,
@@ -550,12 +580,11 @@ async def get_version_download_response(
file_path = Path(version.file_uri) file_path = Path(version.file_uri)
if not file_path.exists(): if not file_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在")
filename = file_path.name filename = version.original_filename or _legacy_download_filename(version, doc)
return FileResponse( return FileResponse(
path=str(file_path), path=str(file_path),
filename=filename,
media_type=version.mime_type or "application/octet-stream", media_type=version.mime_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{filename}"'}, headers={"Content-Disposition": _content_disposition(filename)},
) )
@@ -661,6 +690,10 @@ async def create_acknowledgement(
if distribution.target_type == DistributionTargetType.ROLE: 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", "")): 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="不在分发范围内") 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: if payload.ack_type != AcknowledgementType.RECEIVED:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执") raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执")
@@ -695,6 +728,12 @@ async def create_acknowledgement(
operator_role=await get_operator_role_label(db, doc.trial_id, current_user), 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.commit()
await db.refresh(ack) await db.refresh(ack)
return ack return ack
@@ -778,9 +817,28 @@ async def list_distribution_notifications(
DocumentVersion.version_no, DocumentVersion.version_no,
DocumentVersion.change_summary, DocumentVersion.change_summary,
DocumentVersion.effective_at, DocumentVersion.effective_at,
Study.name.label("study_name"),
DesktopNotificationDelivery.delivered_at,
DesktopNotificationDelivery.read_at,
) )
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id) .join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
.join(Document, Distribution.document_id == Document.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.notification_id == Notification.id,
DesktopNotificationDelivery.user_id == current_user.id,
),
)
.where( .where(
Document.trial_id == study_id, Document.trial_id == study_id,
Distribution.status == DistributionStatus.ACTIVE, Distribution.status == DistributionStatus.ACTIVE,
@@ -804,6 +862,10 @@ async def list_distribution_notifications(
change_summary=row.change_summary, change_summary=row.change_summary,
effective_at=row.effective_at, effective_at=row.effective_at,
created_at=row.created_at, created_at=row.created_at,
study_id=study_id,
study_name=row.study_name,
delivered_at=row.delivered_at,
read_at=row.read_at,
) )
) )
return items return items
+563
View File
@@ -0,0 +1,563 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import random
import secrets
import smtplib
import ssl
import uuid
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from email.utils import formataddr
from cryptography.fernet import Fernet, InvalidToken
from fastapi import HTTPException, status
from sqlalchemy import delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import hash_password, verify_password
from app.crud import user as user_crud
from app.models.email_settings import EmailVerificationCode, EmailVerificationPurpose, SmtpSecurity, SystemEmailSettings
from app.schemas.email_settings import EmailSettingsRead, EmailSettingsUpdate
def _normalize_email(email: str) -> str:
return email.strip().lower()
def _parse_allowed_domains(value: str) -> list[str]:
domains: list[str] = []
for item in (value or "").replace("", ",").split(","):
domain = item.strip().lower().lstrip("@")
if domain and domain not in domains:
domains.append(domain)
return domains or ["huapont.cn", "qq.com"]
def _normalize_domain(value: str) -> str:
return value.strip().lower().lstrip("@")
def _domain_from_email(email: str) -> str:
parts = _normalize_email(email).rsplit("@", 1)
return parts[1] if len(parts) == 2 else ""
def _as_aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def _get_fernet() -> Fernet:
source = settings.SETTINGS_ENCRYPTION_KEY or settings.JWT_SECRET_KEY
try:
return Fernet(source.encode("utf-8"))
except Exception:
key = base64.urlsafe_b64encode(hashlib.sha256(source.encode("utf-8")).digest())
return Fernet(key)
def encrypt_secret(value: str) -> str:
return _get_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
try:
return _get_fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="邮件授权码解密失败") from exc
async def list_email_settings(db: AsyncSession) -> list[SystemEmailSettings]:
result = await db.execute(select(SystemEmailSettings).order_by(SystemEmailSettings.register_domain.asc()))
return list(result.scalars().all())
async def get_email_settings(db: AsyncSession, register_domain: str | None = None) -> SystemEmailSettings | None:
if register_domain:
result = await db.execute(
select(SystemEmailSettings).where(SystemEmailSettings.register_domain == _normalize_domain(register_domain))
)
return result.scalar_one_or_none()
result = await db.execute(select(SystemEmailSettings).order_by(SystemEmailSettings.created_at.asc()))
return result.scalars().first()
async def get_email_settings_for_email(db: AsyncSession, email: str) -> SystemEmailSettings | None:
domain = _domain_from_email(email)
return await get_email_settings(db, domain) if domain else None
def to_email_settings_read(settings_row: SystemEmailSettings | None) -> EmailSettingsRead:
if not settings_row:
return EmailSettingsRead()
return EmailSettingsRead(
register_domain=settings_row.register_domain,
smtp_host=settings_row.smtp_host,
smtp_port=settings_row.smtp_port,
smtp_security=settings_row.smtp_security.value,
smtp_username=settings_row.smtp_username,
smtp_password_configured=bool(settings_row.smtp_password_encrypted),
sender_email=settings_row.sender_email,
sender_name=settings_row.sender_name,
allowed_register_domain=settings_row.allowed_register_domain,
verification_code_ttl_minutes=settings_row.verification_code_ttl_minutes,
send_cooldown_seconds=settings_row.send_cooldown_seconds,
max_verify_attempts=settings_row.max_verify_attempts,
updated_at=settings_row.updated_at,
)
async def upsert_email_settings(
db: AsyncSession,
register_domain: str,
payload: EmailSettingsUpdate,
*,
updated_by: uuid.UUID,
) -> SystemEmailSettings:
register_domain = _normalize_domain(register_domain)
settings_row = await get_email_settings(db, register_domain)
if not settings_row:
settings_row = SystemEmailSettings(
register_domain=register_domain,
smtp_host=payload.smtp_host,
smtp_port=payload.smtp_port,
smtp_security=SmtpSecurity(payload.smtp_security),
smtp_username=payload.smtp_username,
smtp_password_encrypted=encrypt_secret(payload.smtp_password) if payload.smtp_password else None,
sender_email=str(payload.sender_email),
sender_name=payload.sender_name,
allowed_register_domain=register_domain,
verification_code_ttl_minutes=payload.verification_code_ttl_minutes,
send_cooldown_seconds=payload.send_cooldown_seconds,
max_verify_attempts=payload.max_verify_attempts,
updated_by=updated_by,
)
db.add(settings_row)
else:
settings_row.smtp_host = payload.smtp_host
settings_row.smtp_port = payload.smtp_port
settings_row.smtp_security = SmtpSecurity(payload.smtp_security)
settings_row.smtp_username = payload.smtp_username
if payload.smtp_password:
settings_row.smtp_password_encrypted = encrypt_secret(payload.smtp_password)
settings_row.sender_email = str(payload.sender_email)
settings_row.sender_name = payload.sender_name
settings_row.allowed_register_domain = register_domain
settings_row.verification_code_ttl_minutes = payload.verification_code_ttl_minutes
settings_row.send_cooldown_seconds = payload.send_cooldown_seconds
settings_row.max_verify_attempts = payload.max_verify_attempts
settings_row.updated_by = updated_by
await db.commit()
await db.refresh(settings_row)
return settings_row
async def create_email_domain(db: AsyncSession, register_domain: str, *, updated_by: uuid.UUID) -> SystemEmailSettings:
register_domain = _normalize_domain(register_domain)
existing = await get_email_settings(db, register_domain)
if existing:
return existing
settings_row = SystemEmailSettings(
register_domain=register_domain,
smtp_host="",
smtp_port=465,
smtp_security=SmtpSecurity.SSL,
smtp_username="",
smtp_password_encrypted=None,
sender_email="",
sender_name="CTMS 系统",
allowed_register_domain=register_domain,
verification_code_ttl_minutes=10,
send_cooldown_seconds=60,
max_verify_attempts=5,
updated_by=updated_by,
)
db.add(settings_row)
await db.commit()
await db.refresh(settings_row)
return settings_row
async def delete_email_domain(db: AsyncSession, register_domain: str) -> None:
await db.execute(
delete(SystemEmailSettings).where(SystemEmailSettings.register_domain == _normalize_domain(register_domain))
)
await db.commit()
def ensure_email_settings_ready(settings_row: SystemEmailSettings | None) -> SystemEmailSettings:
if not settings_row:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮件服务尚未配置")
if not settings_row.smtp_host or not settings_row.smtp_username or not settings_row.sender_email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮件服务配置不完整")
if not settings_row.smtp_password_encrypted:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SMTP 授权码尚未配置")
return settings_row
def ensure_allowed_register_email(email: str, settings_row: SystemEmailSettings) -> None:
domain = settings_row.register_domain
if not _normalize_email(email).endswith(f"@{domain}"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"仅允许 @{domain} 邮箱注册")
def _send_email_sync(
settings_row: SystemEmailSettings,
*,
to_email: str,
subject: str,
body: str,
) -> None:
password = decrypt_secret(settings_row.smtp_password_encrypted)
if not password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SMTP 授权码尚未配置")
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr((settings_row.sender_name or "", settings_row.sender_email))
message["To"] = to_email
message.set_content(body)
if settings_row.smtp_security == SmtpSecurity.SSL:
with smtplib.SMTP_SSL(settings_row.smtp_host, settings_row.smtp_port, timeout=15) as smtp:
smtp.login(settings_row.smtp_username, password)
smtp.send_message(message)
else:
with smtplib.SMTP(settings_row.smtp_host, settings_row.smtp_port, timeout=15) as smtp:
if settings_row.smtp_security == SmtpSecurity.STARTTLS:
smtp.starttls()
smtp.login(settings_row.smtp_username, password)
smtp.send_message(message)
async def send_email(
settings_row: SystemEmailSettings,
*,
to_email: str,
subject: str,
body: str,
) -> None:
try:
await asyncio.to_thread(_send_email_sync, settings_row, to_email=to_email, subject=subject, body=body)
except HTTPException:
raise
except ssl.SSLError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(
"邮件发送失败:SMTP 安全协议或端口不匹配,"
"请确认当前后缀的 SMTP 地址、端口和安全协议。"
"SSL 通常使用 465STARTTLS 通常使用 587。"
),
) from exc
except smtplib.SMTPAuthenticationError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="邮件发送失败:SMTP 账号或授权码不正确",
) from exc
except (smtplib.SMTPRecipientsRefused, smtplib.SMTPDataError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="邮箱不存在或无法接收邮件,请检查邮箱地址",
) from exc
except (smtplib.SMTPConnectError, TimeoutError, OSError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="邮件发送失败:无法连接 SMTP 服务器,请检查 SMTP 地址、端口和网络连通性",
) from exc
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"邮件发送失败:{exc}") from exc
async def send_test_email(db: AsyncSession, register_domain: str, recipient_email: str) -> None:
settings_row = ensure_email_settings_ready(await get_email_settings(db, register_domain))
await send_email(
settings_row,
to_email=recipient_email,
subject="CTMS 邮件服务测试",
body="这是一封来自 CTMS 系统的邮件服务测试邮件。收到此邮件说明 SMTP 配置可用。",
)
async def send_register_code(db: AsyncSession, email: str) -> None:
email = _normalize_email(email)
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
ensure_allowed_register_email(email, settings_row)
if await user_crud.get_by_email(db, email):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await _send_verification_code(
db,
settings_row=settings_row,
email=email,
purpose=EmailVerificationPurpose.REGISTER,
subject="CTMS 注册邮箱验证码",
body_factory=lambda code: (
f"您的 CTMS 注册验证码是:{code}\n\n"
f"验证码 {settings_row.verification_code_ttl_minutes} 分钟内有效。"
"如非本人操作,请忽略此邮件。"
),
)
async def send_password_reset_code(db: AsyncSession, email: str) -> None:
email = _normalize_email(email)
db_user = await user_crud.get_by_email(db, email)
if not db_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="该邮箱未注册")
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
await _send_verification_code(
db,
settings_row=settings_row,
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
subject="CTMS 密码重置验证码",
body_factory=lambda code: (
f"您的 CTMS 密码重置验证码是:{code}\n\n"
f"验证码 {settings_row.verification_code_ttl_minutes} 分钟内有效。"
"如非本人操作,请尽快联系系统管理员。"
),
)
async def send_password_reset_link(db: AsyncSession, email: str, *, frontend_origin: str) -> None:
email = _normalize_email(email)
db_user = await user_crud.get_by_email(db, email)
if not db_user:
return
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
now = datetime.now(timezone.utc)
latest_result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == EmailVerificationPurpose.PASSWORD_RESET_LINK,
)
.order_by(desc(EmailVerificationCode.created_at))
.limit(1)
)
latest = latest_result.scalar_one_or_none()
if latest and latest.created_at and _as_aware(latest.created_at) + timedelta(seconds=settings_row.send_cooldown_seconds) > now:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="重置邮件发送过于频繁,请稍后再试")
token = secrets.token_urlsafe(32)
token_hash = _hash_reset_token(token)
record = EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
code_hash=token_hash,
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
)
db.add(record)
await db.commit()
origin = frontend_origin.rstrip("/") if frontend_origin else ""
reset_link = f"{origin}/forgot-password?token={token}" if origin else f"/forgot-password?token={token}"
await send_email(
settings_row,
to_email=email,
subject="CTMS 密码重置链接",
body=(
"请点击以下链接重置您的 CTMS 登录密码:\n\n"
f"{reset_link}\n\n"
f"链接 {settings_row.verification_code_ttl_minutes} 分钟内有效,且只能使用一次。"
"如非本人操作,请忽略此邮件并尽快联系系统管理员。"
),
)
def _hash_reset_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def _send_verification_code(
db: AsyncSession,
*,
settings_row: SystemEmailSettings,
email: str,
purpose: EmailVerificationPurpose,
subject: str,
body_factory,
) -> None:
now = datetime.now(timezone.utc)
latest_result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
)
.order_by(desc(EmailVerificationCode.created_at))
.limit(1)
)
latest = latest_result.scalar_one_or_none()
if latest and latest.created_at and _as_aware(latest.created_at) + timedelta(seconds=settings_row.send_cooldown_seconds) > now:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="验证码发送过于频繁,请稍后再试")
code = f"{random.SystemRandom().randint(0, 999999):06d}"
record = EmailVerificationCode(
email=email,
purpose=purpose,
code_hash=hash_password(code),
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
)
db.add(record)
await db.commit()
await send_email(
settings_row,
to_email=email,
subject=subject,
body=body_factory(code),
)
async def verify_register_code(db: AsyncSession, email: str, code: str) -> bool:
email = _normalize_email(email)
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
ensure_allowed_register_email(email, settings_row)
await _verify_email_code(
db,
settings_row=settings_row,
email=email,
code=code,
purpose=EmailVerificationPurpose.REGISTER,
mark_verified=True,
)
return True
async def verify_password_reset_code(db: AsyncSession, email: str, code: str) -> str:
email = _normalize_email(email)
if not await user_crud.get_by_email(db, email):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
await _verify_email_code(
db,
settings_row=settings_row,
email=email,
code=code,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
mark_verified=True,
commit=False,
)
now = datetime.now(timezone.utc)
token = secrets.token_urlsafe(32)
db.add(
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
code_hash=_hash_reset_token(token),
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
)
)
await db.commit()
return token
async def reset_password_with_code(db: AsyncSession, email: str, code: str, password: str) -> None:
email = _normalize_email(email)
db_user = await user_crud.get_by_email(db, email)
if not db_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
await _verify_email_code(
db,
settings_row=settings_row,
email=email,
code=code,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
mark_verified=True,
commit=False,
)
db_user.password_hash = hash_password(password)
await db.commit()
async def reset_password_with_token(db: AsyncSession, token: str, password: str) -> None:
now = datetime.now(timezone.utc)
token_hash = _hash_reset_token(token)
result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.code_hash == token_hash,
EmailVerificationCode.purpose == EmailVerificationPurpose.PASSWORD_RESET_LINK,
EmailVerificationCode.verified_at.is_(None),
)
.order_by(desc(EmailVerificationCode.created_at))
.limit(1)
.with_for_update()
)
record = result.scalar_one_or_none()
if not record or record.verified_at is not None or _as_aware(record.expires_at) < now:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
db_user = await user_crud.get_by_email(db, record.email)
if not db_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
record.verified_at = now
db_user.password_hash = hash_password(password)
await db.commit()
async def _verify_email_code(
db: AsyncSession,
*,
settings_row: SystemEmailSettings,
email: str,
code: str,
purpose: EmailVerificationPurpose,
mark_verified: bool,
commit: bool = True,
) -> EmailVerificationCode:
now = datetime.now(timezone.utc)
result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
)
.order_by(desc(EmailVerificationCode.created_at))
.limit(1)
.with_for_update()
)
record = result.scalar_one_or_none()
if (
not record
or record.verified_at is not None
or _as_aware(record.expires_at) < now
or record.attempt_count >= settings_row.max_verify_attempts
):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
record.attempt_count += 1
if not verify_password(code, record.code_hash):
await db.commit()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
if mark_verified:
record.verified_at = now
if commit:
await db.commit()
return record
async def ensure_register_email_verified(db: AsyncSession, email: str) -> None:
email = _normalize_email(email)
now = datetime.now(timezone.utc)
result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == EmailVerificationPurpose.REGISTER,
EmailVerificationCode.verified_at.is_not(None),
EmailVerificationCode.expires_at >= now,
)
.order_by(desc(EmailVerificationCode.verified_at))
.limit(1)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先完成邮箱验证码校验")
@@ -0,0 +1,175 @@
"""Canonical map metadata for monitoring source locations.
ip2region provides administrative names but no coordinates. This module keeps
the normalization and centroid contract on the server so web and desktop
clients render the same location semantics.
"""
from __future__ import annotations
from dataclasses import dataclass
from app.services.ip_location import IpLocation
@dataclass(frozen=True)
class GeoLocationMetadata:
country: str
country_code: str
region_code: str
longitude: float | None
latitude: float | None
accuracy_level: str
COUNTRY_ALIASES = {
"中国": "China",
"China": "China",
"Mainland China": "China",
"中国香港": "China",
"中国澳门": "China",
"中国台湾": "China",
"美国": "United States",
"United States": "United States",
"United States of America": "United States",
"USA": "United States",
"土耳其": "Türkiye",
"Turkey": "Türkiye",
"Türkiye": "Türkiye",
}
COUNTRY_CODES = {
"China": "CN",
"United States": "US",
"Netherlands": "NL",
"Türkiye": "TR",
"Australia": "AU",
"Japan": "JP",
"Singapore": "SG",
"Germany": "DE",
"France": "FR",
"United Kingdom": "GB",
"Canada": "CA",
"India": "IN",
"Russia": "RU",
}
COUNTRY_CENTROIDS = {
"China": (104.1954, 35.8617),
"United States": (-95.7129, 37.0902),
"Netherlands": (5.2913, 52.1326),
"Türkiye": (35.2433, 38.9637),
"Australia": (133.7751, -25.2744),
"Japan": (138.2529, 36.2048),
"Singapore": (103.8198, 1.3521),
"Germany": (10.4515, 51.1657),
"France": (2.2137, 46.2276),
"United Kingdom": (-3.436, 55.3781),
"Canada": (-106.3468, 56.1304),
"India": (78.9629, 20.5937),
"Russia": (105.3188, 61.524),
}
CHINA_REGION_METADATA = {
"北京市": ("CN-BJ", 116.4074, 39.9042),
"天津市": ("CN-TJ", 117.2008, 39.0842),
"河北省": ("CN-HE", 114.5025, 38.0455),
"山西省": ("CN-SX", 112.5492, 37.857),
"内蒙古自治区": ("CN-NM", 111.6708, 40.8183),
"辽宁省": ("CN-LN", 123.4315, 41.8057),
"吉林省": ("CN-JL", 125.3245, 43.8868),
"黑龙江省": ("CN-HL", 126.6424, 45.7567),
"上海市": ("CN-SH", 121.4737, 31.2304),
"江苏省": ("CN-JS", 118.7633, 32.0617),
"浙江省": ("CN-ZJ", 120.1551, 30.2741),
"安徽省": ("CN-AH", 117.2272, 31.8206),
"福建省": ("CN-FJ", 119.2965, 26.0745),
"江西省": ("CN-JX", 115.8582, 28.682),
"山东省": ("CN-SD", 117.1201, 36.6512),
"河南省": ("CN-HA", 113.6254, 34.7466),
"湖北省": ("CN-HB", 114.3055, 30.5928),
"湖南省": ("CN-HN", 112.9388, 28.2282),
"广东省": ("CN-GD", 113.2644, 23.1291),
"广西壮族自治区": ("CN-GX", 108.3669, 22.817),
"海南省": ("CN-HI", 110.3312, 20.0311),
"重庆": ("CN-CQ", 106.5516, 29.563),
"重庆市": ("CN-CQ", 106.5516, 29.563),
"四川省": ("CN-SC", 104.0665, 30.5723),
"贵州省": ("CN-GZ", 106.6302, 26.647),
"云南省": ("CN-YN", 102.8329, 24.8801),
"西藏自治区": ("CN-XZ", 91.1322, 29.6604),
"陕西省": ("CN-SN", 108.9398, 34.3416),
"甘肃省": ("CN-GS", 103.8343, 36.0611),
"青海省": ("CN-QH", 101.7782, 36.6171),
"宁夏回族自治区": ("CN-NX", 106.2309, 38.4872),
"新疆维吾尔自治区": ("CN-XJ", 87.6168, 43.8256),
"台湾省": ("CN-TW", 121.5654, 25.033),
"香港特别行政区": ("CN-HK", 114.1694, 22.3193),
"澳门特别行政区": ("CN-MO", 113.5439, 22.1987),
}
CITY_CENTROIDS = {
"南京": (118.7969, 32.0603),
"南京市": (118.7969, 32.0603),
"San Jose": (-121.8863, 37.3382),
"South Holland": (4.493, 52.0208),
"Istanbul": (28.9784, 41.0082),
}
def resolve_geo_location_metadata(location: IpLocation) -> GeoLocationMetadata:
if location.location in {"局域网", "本机"}:
return GeoLocationMetadata(
country="",
country_code="PRIVATE",
region_code="PRIVATE",
longitude=None,
latitude=None,
accuracy_level="private",
)
country = COUNTRY_ALIASES.get(location.country, location.country)
country_code = COUNTRY_CODES.get(country, "")
city_coordinate = CITY_CENTROIDS.get(location.city)
if city_coordinate:
return GeoLocationMetadata(
country=country,
country_code=country_code,
region_code="",
longitude=city_coordinate[0],
latitude=city_coordinate[1],
accuracy_level="city",
)
region_metadata = CHINA_REGION_METADATA.get(location.province)
if country in {"China", "中国"} and region_metadata:
region_code, longitude, latitude = region_metadata
return GeoLocationMetadata(
country="China",
country_code="CN",
region_code=region_code,
longitude=longitude,
latitude=latitude,
accuracy_level="region",
)
country_coordinate = COUNTRY_CENTROIDS.get(country)
if country_coordinate:
return GeoLocationMetadata(
country=country,
country_code=country_code,
region_code="",
longitude=country_coordinate[0],
latitude=country_coordinate[1],
accuracy_level="country",
)
return GeoLocationMetadata(
country=country,
country_code=country_code,
region_code="",
longitude=None,
latitude=None,
accuracy_level="unknown",
)
@@ -0,0 +1,174 @@
"""Bounded third-party coordinate fallback for public source IPs.
IPAddress.my identifies IP2Location.io as its data provider. We use the
provider's documented JSON API instead of scraping the public HTML page.
Only globally routable addresses are eligible, and results are cached so the
monitoring UI does not turn into a per-refresh third-party lookup fan-out.
"""
from __future__ import annotations
import asyncio
import ipaddress
import logging
import math
import time
from dataclasses import dataclass
from typing import Any, Iterable
import httpx
from app.core.config import settings
from app.services.geo_location_metadata import GeoLocationMetadata
from app.services.ip_location import IpLocation
logger = logging.getLogger("ctms.ip_geolocation_fallback")
_API_URL = "https://api.ip2location.io/"
_MAX_RESPONSE_BYTES = 64 * 1024
_MAX_CACHE_ENTRIES = 4096
_NEGATIVE_CACHE_SECONDS = 3600
_cache: dict[str, tuple[float, "ExternalIpLocation | None"]] = {}
_cache_lock = asyncio.Lock()
def _text(value: Any, limit: int = 160) -> str:
candidate = str(value or "").strip()
return "" if candidate in {"", "-", "0"} else candidate[:limit]
def _coordinate(value: Any, *, minimum: float, maximum: float) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) and minimum <= number <= maximum else None
def _global_ip(value: str | None) -> str | None:
try:
address = ipaddress.ip_address((value or "").strip())
except ValueError:
return None
return str(address) if address.is_global else None
@dataclass(frozen=True)
class ExternalIpLocation:
ip_address: str
country: str
country_code: str
region: str
city: str
isp: str
longitude: float
latitude: float
def merge_ip_location(self, local: IpLocation) -> IpLocation:
country = self.country or local.country
province = self.region or local.province
city = self.city or local.city
isp = self.isp or local.isp
location = " / ".join(part for part in [country, province, city, isp] if part) or local.location
return IpLocation(location=location, country=country, province=province, city=city, isp=isp)
def to_metadata(self) -> GeoLocationMetadata:
return GeoLocationMetadata(
country=self.country,
country_code=self.country_code,
region_code="",
longitude=self.longitude,
latitude=self.latitude,
accuracy_level="city" if self.city else "country",
)
def _parse_response(expected_ip: str, payload: Any) -> ExternalIpLocation | None:
if not isinstance(payload, dict):
return None
response_ip = _global_ip(_text(payload.get("ip"), 45))
if response_ip != expected_ip:
return None
latitude = _coordinate(payload.get("latitude"), minimum=-90, maximum=90)
longitude = _coordinate(payload.get("longitude"), minimum=-180, maximum=180)
if latitude is None or longitude is None:
return None
country_code = _text(payload.get("country_code"), 2).upper()
if len(country_code) != 2:
country_code = ""
return ExternalIpLocation(
ip_address=expected_ip,
country=_text(payload.get("country_name"), 100),
country_code=country_code,
region=_text(payload.get("region_name"), 100),
city=_text(payload.get("city_name"), 100),
isp=_text(payload.get("isp"), 160),
longitude=longitude,
latitude=latitude,
)
async def _fetch_one(
client: httpx.AsyncClient,
semaphore: asyncio.Semaphore,
ip_address: str,
) -> ExternalIpLocation | None:
async with semaphore:
try:
response = await client.get(_API_URL, params={"ip": ip_address, "format": "json"})
response.raise_for_status()
if len(response.content) > _MAX_RESPONSE_BYTES:
return None
return _parse_response(ip_address, response.json())
except (httpx.HTTPError, ValueError):
logger.warning("External IP coordinate fallback unavailable")
return None
async def resolve_external_ip_locations(ip_addresses: Iterable[str]) -> dict[str, ExternalIpLocation]:
if not settings.MONITORING_IP_GEO_FALLBACK_ENABLED or settings.ENV == "test":
return {}
candidates = list(dict.fromkeys(filter(None, (_global_ip(value) for value in ip_addresses))))
if not candidates:
return {}
now = time.monotonic()
resolved: dict[str, ExternalIpLocation] = {}
pending: list[str] = []
async with _cache_lock:
for ip_address in candidates:
cached = _cache.get(ip_address)
if cached and cached[0] > now:
if cached[1] is not None:
resolved[ip_address] = cached[1]
continue
pending.append(ip_address)
pending = pending[: settings.MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS]
if not pending:
return resolved
headers = {"Accept": "application/json", "User-Agent": "CTMS-IP-Coordinate-Fallback/1.0"}
api_key = (settings.MONITORING_IP_GEO_FALLBACK_API_KEY or "").strip()
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
timeout = httpx.Timeout(settings.MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS)
semaphore = asyncio.Semaphore(min(5, len(pending)))
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, headers=headers) as client:
fetched = await asyncio.gather(*(_fetch_one(client, semaphore, item) for item in pending))
now = time.monotonic()
async with _cache_lock:
for ip_address, item in zip(pending, fetched):
ttl = settings.MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS if item else _NEGATIVE_CACHE_SECONDS
_cache[ip_address] = (now + ttl, item)
if item is not None:
resolved[ip_address] = item
while len(_cache) > _MAX_CACHE_ENTRIES:
_cache.pop(next(iter(_cache)))
return resolved
def reset_ip_geolocation_fallback_cache() -> None:
_cache.clear()
+2
View File
@@ -9,6 +9,7 @@ import ipaddress
import logging import logging
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -125,5 +126,6 @@ class Ip2RegionResolver:
_resolver = Ip2RegionResolver() _resolver = Ip2RegionResolver()
@lru_cache(maxsize=8192)
def resolve_ip_location(ip: str | None) -> IpLocation: def resolve_ip_location(ip: str | None) -> IpLocation:
return _resolver.lookup(ip) return _resolver.lookup(ip)
@@ -0,0 +1,151 @@
"""监测数据留存清理任务。"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import delete
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
from app.models.security_access_log import SecurityAccessLog
from app.models.source_location_snapshot import SourceLocationSnapshot
from app.models.user_login_session import UserLoginSession
logger = logging.getLogger("ctms.monitoring_retention")
@dataclass
class MonitoringRetentionState:
running: bool = False
last_run_started_at: datetime | None = None
last_success_at: datetime | None = None
last_error_at: datetime | None = None
last_error_type: str | None = None
error_count: int = 0
last_deleted: dict[str, int] = field(default_factory=dict)
total_deleted: dict[str, int] = field(default_factory=dict)
def snapshot(self) -> dict[str, Any]:
return {
"running": self.running,
"access_log_retention_days": settings.MONITORING_ACCESS_LOG_RETENTION_DAYS,
"metric_retention_days": settings.MONITORING_METRIC_RETENTION_DAYS,
"login_activity_retention_days": settings.USER_LOGIN_ACTIVITY_RETENTION_DAYS,
"interval_seconds": settings.MONITORING_RETENTION_INTERVAL_SECONDS,
"last_run_started_at": (
self.last_run_started_at.isoformat() if self.last_run_started_at else None
),
"last_success_at": self.last_success_at.isoformat() if self.last_success_at else None,
"last_error_at": self.last_error_at.isoformat() if self.last_error_at else None,
"last_error_type": self.last_error_type,
"error_count": self.error_count,
"last_deleted": dict(self.last_deleted),
"total_deleted": dict(self.total_deleted),
}
_state = MonitoringRetentionState()
def get_monitoring_retention_status() -> dict[str, Any]:
return _state.snapshot()
def _deleted_count(result: Any) -> int:
rowcount = getattr(result, "rowcount", 0)
return max(0, int(rowcount or 0))
async def purge_expired_monitoring_data(
*, now: datetime | None = None
) -> dict[str, int]:
reference_time = now or datetime.now(timezone.utc)
access_cutoff = reference_time - timedelta(
days=settings.MONITORING_ACCESS_LOG_RETENTION_DAYS
)
metric_cutoff = reference_time - timedelta(
days=settings.MONITORING_METRIC_RETENTION_DAYS
)
login_activity_cutoff = reference_time - timedelta(
days=settings.USER_LOGIN_ACTIVITY_RETENTION_DAYS
)
async with SessionLocal() as session:
permission_result = await session.execute(
delete(PermissionAccessLog).where(PermissionAccessLog.created_at < access_cutoff)
)
security_result = await session.execute(
delete(SecurityAccessLog).where(SecurityAccessLog.created_at < access_cutoff)
)
metric_result = await session.execute(
delete(PermissionMetricSnapshot).where(
PermissionMetricSnapshot.bucket_time < metric_cutoff
)
)
source_location_result = await session.execute(
delete(SourceLocationSnapshot).where(
SourceLocationSnapshot.bucket_time < access_cutoff
)
)
login_session_result = await session.execute(
delete(UserLoginSession).where(UserLoginSession.last_seen_at < login_activity_cutoff)
)
await session.commit()
return {
"permission_access_logs": _deleted_count(permission_result),
"security_access_logs": _deleted_count(security_result),
"permission_metric_snapshots": _deleted_count(metric_result),
"source_location_snapshots": _deleted_count(source_location_result),
"user_login_sessions": _deleted_count(login_session_result),
}
async def run_monitoring_retention_once(
*, now: datetime | None = None
) -> dict[str, int]:
_state.last_run_started_at = now or datetime.now(timezone.utc)
try:
deleted = await purge_expired_monitoring_data(now=now)
except Exception as exc:
_state.last_error_at = datetime.now(timezone.utc)
_state.last_error_type = type(exc).__name__
_state.error_count += 1
raise
_state.last_success_at = datetime.now(timezone.utc)
_state.last_deleted = deleted
for key, count in deleted.items():
_state.total_deleted[key] = _state.total_deleted.get(key, 0) + count
return deleted
async def run_monitoring_retention(stop_event: asyncio.Event) -> None:
logger.info("Monitoring retention task started")
_state.running = True
try:
while not stop_event.is_set():
try:
deleted = await run_monitoring_retention_once()
if any(deleted.values()):
logger.info("Purged expired monitoring data: %s", deleted)
except Exception:
logger.exception("Failed to purge expired monitoring data")
try:
await asyncio.wait_for(
stop_event.wait(),
timeout=settings.MONITORING_RETENTION_INTERVAL_SECONDS,
)
except asyncio.TimeoutError:
continue
finally:
_state.running = False
logger.info("Monitoring retention task stopped")
@@ -0,0 +1,170 @@
"""Resolve the monitoring deployment's public network location.
The map server marker is derived from the deployment's public IP instead of a
frontend or configuration coordinate. An explicit public IP is accepted for
restricted networks; otherwise the public frontend hostname and, finally, a
small set of public-IP discovery endpoints are used. Results are cached so the
monitoring API never performs per-request network discovery.
"""
from __future__ import annotations
import asyncio
import ipaddress
import logging
import socket
import time
from dataclasses import dataclass
from urllib.parse import urlparse
import httpx
from app.core.config import settings
from app.services.geo_location_metadata import resolve_geo_location_metadata
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
from app.services.ip_location import resolve_ip_location
logger = logging.getLogger("ctms.monitoring_server_location")
@dataclass(frozen=True)
class MonitoringServerLocation:
name: str
longitude: float
latitude: float
accuracy_level: str
resolution_source: str
def to_public_dict(self) -> dict:
return {
"name": self.name,
"longitude": self.longitude,
"latitude": self.latitude,
"accuracy_level": self.accuracy_level,
"resolution_source": self.resolution_source,
}
_cached_location: MonitoringServerLocation | None = None
_cache_initialized = False
_cache_expires_at = 0.0
_cache_lock = asyncio.Lock()
def _normalize_public_ip(value: str | None) -> str | None:
candidate = (value or "").strip().splitlines()[0] if (value or "").strip() else ""
try:
address = ipaddress.ip_address(candidate)
except ValueError:
return None
return str(address) if address.is_global else None
async def _resolve_frontend_public_ip() -> str | None:
hostname = urlparse(settings.FRONTEND_PUBLIC_URL).hostname
if not hostname:
return None
literal_ip = _normalize_public_ip(hostname)
if literal_ip:
return literal_ip
try:
loop = asyncio.get_running_loop()
records = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except (OSError, socket.gaierror):
return None
candidates = []
for _, _, _, _, socket_address in records:
candidate = _normalize_public_ip(str(socket_address[0]))
if candidate and candidate not in candidates:
candidates.append(candidate)
return next((item for item in candidates if ":" not in item), candidates[0] if candidates else None)
async def _discover_public_ip() -> tuple[str | None, str]:
configured_ip = _normalize_public_ip(settings.MONITORING_SERVER_PUBLIC_IP)
if configured_ip:
return configured_ip, "configured_public_ip"
frontend_ip = await _resolve_frontend_public_ip()
if frontend_ip:
return frontend_ip, "frontend_dns"
if settings.ENV == "test":
return None, "unavailable"
urls = [
item.strip()
for item in settings.MONITORING_PUBLIC_IP_DISCOVERY_URLS.split(",")
if item.strip()
]
timeout = httpx.Timeout(settings.MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
for url in urls:
try:
response = await client.get(url, headers={"Accept": "text/plain"})
response.raise_for_status()
except httpx.HTTPError:
logger.warning("Public IP discovery endpoint unavailable", extra={"endpoint": url})
continue
discovered_ip = _normalize_public_ip(response.text[:128])
if discovered_ip:
return discovered_ip, "public_ip_discovery"
return None, "unavailable"
async def resolve_monitoring_server_location(*, force: bool = False) -> MonitoringServerLocation | None:
global _cached_location, _cache_initialized, _cache_expires_at
now = time.monotonic()
if not force and _cache_initialized and now < _cache_expires_at:
return _cached_location
async with _cache_lock:
now = time.monotonic()
if not force and _cache_initialized and now < _cache_expires_at:
return _cached_location
public_ip, resolution_source = await _discover_public_ip()
location = None
if public_ip:
ip_location = resolve_ip_location(public_ip)
metadata = resolve_geo_location_metadata(ip_location)
if metadata.longitude is None or metadata.latitude is None:
external_location = (await resolve_external_ip_locations([public_ip])).get(public_ip)
if external_location is not None:
ip_location = external_location.merge_ip_location(ip_location)
metadata = external_location.to_metadata()
if metadata.longitude is not None and metadata.latitude is not None:
country = metadata.country or ip_location.country
name = " / ".join(
part for part in [country, ip_location.province, ip_location.city] if part
) or "公网服务器"
location = MonitoringServerLocation(
name=name,
longitude=metadata.longitude,
latitude=metadata.latitude,
accuracy_level=metadata.accuracy_level,
resolution_source=resolution_source,
)
logger.info(
"Monitoring server location resolved",
extra={"resolution_source": resolution_source, "accuracy_level": metadata.accuracy_level},
)
else:
logger.warning("Deployment public IP has no usable geo coordinates")
else:
logger.warning("Unable to discover the deployment public IP")
_cached_location = location
_cache_initialized = True
cache_seconds = settings.MONITORING_SERVER_LOCATION_CACHE_SECONDS if location else 300
_cache_expires_at = now + cache_seconds
return location
def reset_monitoring_server_location_cache() -> None:
"""Reset process-local state for configuration reloads and tests."""
global _cached_location, _cache_initialized, _cache_expires_at
_cached_location = None
_cache_initialized = False
_cache_expires_at = 0.0
@@ -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")
@@ -0,0 +1,366 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Iterable
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,
*,
study_id: uuid.UUID,
recipient_ids: Iterable[uuid.UUID],
category: str,
priority: str,
title: str,
message: str,
action_path: str | None,
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:
return
existing = set((await db.scalars(
select(Notification.recipient_id).where(
Notification.recipient_id.in_(recipients),
Notification.dedupe_key == dedupe_key,
)
)).all())
for recipient_id in recipients - existing:
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,
))
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,
*,
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,
count: int,
due_at: datetime | None = None,
) -> None:
dedupe_key = f"aggregate:{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 count <= 0:
if item and item.resolved_at is None:
item.resolved_at = now
item.read_at = item.read_at or now
return
version = str(count)
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=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.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))
)
async def list_feed(
db: AsyncSession,
*,
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,
Notification.recipient_id == recipient_id,
Notification.resolved_at.is_(None),
)
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(*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,
total_count=total_count,
items=list(items),
)
async def mark_read(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
notification_id: uuid.UUID,
) -> Notification:
item = await db.scalar(select(Notification).where(
Notification.id == notification_id,
Notification.study_id == study_id,
Notification.recipient_id == recipient_id,
Notification.resolved_at.is_(None),
))
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
if item.read_at is None:
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
async def mark_all_read(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
) -> None:
now = datetime.now(timezone.utc)
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_(False),
)
.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()
@@ -0,0 +1,417 @@
from __future__ import annotations
import hashlib
import hmac
import json
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import httpx
from fastapi import HTTPException, Request, status
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.collaboration import (
CollaborationCallbackReceipt,
CollaborationFile,
CollaborationRevision,
CollaborationSession,
CollaborationShareLink,
)
from app.models.user import User
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
from app.services import collaboration_service, onlyoffice_service
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
fingerprint = f"{settings.ONLYOFFICE_INSTANCE_ID or ''}:collaboration:{file_id}:{generation}"
return f"ctms-collab-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}"
def _content_url(session_id: uuid.UUID) -> str:
return (
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
f"/internal/onlyoffice/collaboration/sessions/{session_id}/content"
)
def _callback_url(session_id: uuid.UUID) -> str:
return (
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
f"/internal/onlyoffice/collaboration/sessions/{session_id}/callback"
)
async def _active_session(
db: AsyncSession, item: CollaborationFile, user_id: uuid.UUID
) -> CollaborationSession:
session = await db.scalar(
select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
CollaborationSession.generation == item.generation,
).order_by(CollaborationSession.created_at.desc())
)
if session:
if session.status != "ACTIVE":
session.status = "ACTIVE"
session.closed_at = None
await db.commit()
await db.refresh(session)
return session
if not item.current_revision_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
session = CollaborationSession(
file_id=item.id,
base_revision_id=item.current_revision_id,
document_key=collaboration_document_key(item.id, item.generation),
generation=item.generation,
started_by=user_id,
)
db.add(session)
await db.commit()
await db.refresh(session)
return session
async def build_editor_config(
db: AsyncSession, item: CollaborationFile, user
) -> CollaborationEditorConfigRead:
await onlyoffice_service.ensure_onlyoffice_available()
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
can_edit = await collaboration_service.can_edit_file(db, item, user)
can_request_edit = await collaboration_service.can_request_edit_file(db, item, user)
can_download = await collaboration_service.can_export_file(db, item, user)
can_save_as = can_download and await collaboration_service.can_create_file(db, item, user)
session = await _active_session(db, item, user.id)
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
config: dict[str, Any] = {
"type": "desktop",
"documentType": item.file_type,
"document": {
"fileType": item.extension,
"key": session.document_key,
"title": item.title,
"url": _content_url(session.id),
"permissions": {
"chat": False,
"copy": can_download,
"comment": can_edit,
"download": can_download,
# In view mode ONLYOFFICE displays "Edit current file" only
# when edit=true and onRequestEditRights is registered. CTMS
# handles that event as an approval request, not an escalation.
"edit": can_edit or can_request_edit,
"fillForms": False,
"modifyContentControl": can_edit,
"modifyFilter": can_edit,
"print": can_download,
"protect": False,
"review": False,
},
},
"editorConfig": {
"callbackUrl": _callback_url(session.id),
"coEditing": {"mode": "fast", "change": False},
"customization": {
"autosave": True,
"chat": False,
"comments": can_edit,
"forcesave": can_edit,
"help": False,
"plugins": False,
},
"lang": "zh-CN",
"mode": "edit" if can_edit else "view",
"user": {"id": str(user.id), "name": user.full_name},
},
}
config["token"] = jwt.encode(
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
settings.ONLYOFFICE_JWT_SECRET or "",
algorithm="HS256",
)
return CollaborationEditorConfigRead(
file_id=item.id,
file_name=item.title,
access_mode="edit" if can_edit else "view",
can_save_as=can_save_as,
can_download=can_download,
can_request_edit=can_request_edit,
expires_at=expires_at,
config=config,
)
async def build_shared_editor_config(
db: AsyncSession,
item: CollaborationFile,
link: CollaborationShareLink,
*,
client_id: str,
display_name: str,
) -> CollaborationEditorConfigRead:
await onlyoffice_service.ensure_onlyoffice_available()
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件内容不存在")
can_edit = link.access_mode == "EDIT"
session = await _active_session(db, item, item.owner_id)
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
if link.expires_at and link.expires_at < expires_at:
expires_at = link.expires_at
external_user_id = f"share-{link.id.hex[:12]}-{client_id[:32]}"
config: dict[str, Any] = {
"type": "desktop",
"documentType": item.file_type,
"document": {
"fileType": item.extension,
"key": session.document_key,
"title": item.title,
"url": _content_url(session.id),
"permissions": {
"chat": False,
"copy": item.allow_export,
"comment": can_edit,
"download": item.allow_export,
"edit": can_edit,
"fillForms": False,
"modifyContentControl": can_edit,
"modifyFilter": can_edit,
"print": item.allow_export,
"protect": False,
"review": False,
},
},
"editorConfig": {
"callbackUrl": _callback_url(session.id),
"coEditing": {"mode": "fast", "change": False},
"customization": {
"autosave": can_edit,
"chat": False,
"comments": can_edit,
"forcesave": can_edit,
"help": False,
"plugins": False,
},
"lang": "zh-CN",
"mode": "edit" if can_edit else "view",
"user": {"id": external_user_id, "name": display_name},
},
}
config["token"] = jwt.encode(
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
settings.ONLYOFFICE_JWT_SECRET or "",
algorithm="HS256",
)
return CollaborationEditorConfigRead(
file_id=item.id,
file_name=item.title,
access_mode="edit" if can_edit else "view",
can_save_as=False,
can_download=item.allow_export,
expires_at=expires_at,
config=config,
)
async def get_session_content(
db: AsyncSession, session_id: uuid.UUID, authorization: str | None
) -> tuple[CollaborationRevision, CollaborationFile]:
session = await db.get(CollaborationSession, session_id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
onlyoffice_service.validate_outbox_token(authorization, _content_url(session_id))
revision = await db.get(CollaborationRevision, session.base_revision_id)
item = await db.get(CollaborationFile, session.file_id)
if not revision or not item or not Path(revision.file_uri).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
return revision, item
def validate_callback_token(token: str | None, payload: CollaborationCallbackPayload) -> dict[str, Any]:
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少 ONLYOFFICE 回调签名")
value = token.strip()
if " " in value:
scheme, credential = value.split(" ", 1)
if scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名格式无效")
value = credential.strip()
try:
decoded = onlyoffice_service.decode_onlyoffice_token(value)
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名无效") from exc
signed = decoded.get("payload") if isinstance(decoded.get("payload"), dict) else decoded
if not isinstance(signed, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调载荷无效")
signed_key = signed.get("key")
signed_status = signed.get("status")
if not isinstance(signed_key, str) or not hmac.compare_digest(signed_key, payload.key):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调 key 不匹配")
if not isinstance(signed_status, int) or signed_status != payload.status:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调状态不匹配")
if payload.url:
signed_url = signed.get("url")
if not isinstance(signed_url, str) or not hmac.compare_digest(signed_url, payload.url):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调文件地址不匹配")
return decoded
def _callback_fingerprint(payload: CollaborationCallbackPayload) -> str:
normalized = payload.model_dump(mode="json", exclude_none=True)
return hashlib.sha256(json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _url_origin_matches(actual, expected) -> bool:
actual_port = actual.port or (443 if actual.scheme == "https" else 80)
expected_port = expected.port or (443 if expected.scheme == "https" else 80)
return (
actual.scheme == expected.scheme
and actual.hostname
and actual.hostname.lower() == (expected.hostname or "").lower()
and actual_port == expected_port
)
def _validate_result_url(url: str) -> str:
actual = urlsplit(url)
if (
actual.scheme not in {"http", "https"}
or actual.username
or actual.password
or actual.fragment
or not actual.hostname
):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
internal = urlsplit(settings.ONLYOFFICE_INTERNAL_URL.rstrip("/"))
if _url_origin_matches(actual, internal):
return urlunsplit((internal.scheme, internal.netloc, actual.path, actual.query, ""))
public = urlsplit(settings.FRONTEND_PUBLIC_URL.rstrip("/"))
proxy_prefix = "/onlyoffice/"
if not _url_origin_matches(actual, public) or not actual.path.startswith(proxy_prefix):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
internal_path = f"{internal.path.rstrip('/')}/{actual.path[len(proxy_prefix):]}"
return urlunsplit((internal.scheme, internal.netloc, internal_path, actual.query, ""))
async def _download_result(url: str) -> bytes:
download_url = _validate_result_url(url)
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
async with client.stream("GET", download_url) as response:
if response.status_code != status.HTTP_200_OK:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败")
content = bytearray()
async for chunk in response.aiter_bytes():
content.extend(chunk)
if len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="ONLYOFFICE 保存文件超出限制")
except httpx.HTTPError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败") from exc
return bytes(content)
async def _callback_user(
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
) -> User | None:
has_public_share_user = False
for value in payload.users:
if value.startswith("share-"):
has_public_share_user = True
continue
try:
user = await db.get(User, uuid.UUID(value))
except (ValueError, TypeError):
user = None
if user:
return user
if has_public_share_user:
return None
user = await db.get(User, session.started_by)
if not user:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话用户不存在")
return user
async def process_callback(
db: AsyncSession,
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
) -> dict[str, int]:
session = await db.scalar(
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
if not hmac.compare_digest(session.document_key, payload.key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话 key 不匹配")
fingerprint = _callback_fingerprint(payload)
duplicate = await db.scalar(select(CollaborationCallbackReceipt.id).where(
CollaborationCallbackReceipt.session_id == session.id,
CollaborationCallbackReceipt.fingerprint == fingerprint,
))
if duplicate:
return {"error": 0}
item = await db.get(CollaborationFile, session.file_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
session.last_callback_at = datetime.now(timezone.utc)
session.active_users = json.dumps(payload.users, ensure_ascii=True)
result = "ACKNOWLEDGED"
saved_revision_id = None
if payload.status in {2, 6}:
if not payload.url:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
if session.generation != item.generation:
result = "STALE"
else:
content = await _download_result(payload.url)
actor = await _callback_user(db, payload, session)
source = "SESSION_CLOSE" if payload.status == 2 else "FORCE_SAVE"
if actor is None:
source = "SHARE_SESSION_CLOSE" if payload.status == 2 else "SHARE_FORCE_SAVE"
revision, created = await collaboration_service.append_revision(
db, item, content, source=source, created_by=actor.id if actor else None
)
saved_revision_id = revision.id
result = "SAVED" if created else "UNCHANGED"
if payload.status == 2:
item.generation += 1
session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc)
else:
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document
# Server 缓存重建时仍从最近一次持久化内容恢复。
session.base_revision_id = revision.id
elif payload.status == 4:
session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc)
result = "UNCHANGED"
elif payload.status in {3, 7}:
session.status = "ERROR"
result = "ERROR"
db.add(CollaborationCallbackReceipt(
session_id=session.id,
fingerprint=fingerprint,
callback_status=payload.status,
result=result,
revision_id=saved_revision_id,
))
await db.commit()
# ONLYOFFICE 要求回调处理器在接收并记录状态后固定确认成功。
# status 3/7 表示文档服务自身保存失败,不应通过 error=1 制造重试环。
return {"error": 0}
+235
View File
@@ -0,0 +1,235 @@
from __future__ import annotations
import asyncio
import hashlib
import hmac
import time
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Literal
import httpx
from fastapi import status
from jose import JWTError, jwt
from app.core.config import settings
from app.core.exceptions import AppException
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
OnlyOfficeDocumentType = Literal["word", "cell", "slide"]
OnlyOfficeResourceType = Literal["attachment", "version", "collaboration_revision"]
WORD_FORMATS = frozenset({
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt",
})
CELL_FORMATS = frozenset({
"xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm", "ods", "ots", "csv", "et", "ett",
})
SLIDE_FORMATS = frozenset({
"ppt", "pptx", "pptm", "pps", "ppsx", "ppsm", "pot", "potx", "potm", "odp", "otp", "dps", "dpt",
})
_health_lock = asyncio.Lock()
_health_checked_at = 0.0
_health_available = False
_HEALTH_CACHE_SECONDS = 15.0
def onlyoffice_error(code: str, message: str, status_code: int) -> AppException:
return AppException(code=code, message=message, status_code=status_code)
def office_format_for_filename(filename: str) -> tuple[str, OnlyOfficeDocumentType] | None:
suffix = Path(filename).suffix.lower().lstrip(".")
if suffix in WORD_FORMATS:
return suffix, "word"
if suffix in CELL_FORMATS:
return suffix, "cell"
if suffix in SLIDE_FORMATS:
return suffix, "slide"
return None
def onlyoffice_content_url(resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID) -> str:
plural = {
"attachment": "attachments",
"version": "versions",
"collaboration_revision": "collaboration-revisions",
}[resource_type]
return (
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
f"/internal/onlyoffice/{plural}/{resource_id}/content"
)
def onlyoffice_document_key(
resource_type: OnlyOfficeResourceType,
resource_id: uuid.UUID,
*,
file_hash: str | None = None,
) -> str:
instance_id = (settings.ONLYOFFICE_INSTANCE_ID or "").strip()
fingerprint = f"{instance_id}{resource_type}{resource_id}"
if resource_type == "version":
fingerprint = f"{fingerprint}{file_hash or ''}"
return f"ctms-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}"
async def ensure_onlyoffice_available() -> None:
global _health_available, _health_checked_at
if not settings.ONLYOFFICE_ENABLED:
raise onlyoffice_error(
"ONLYOFFICE_DISABLED",
"Office 预览服务尚未启用",
status.HTTP_503_SERVICE_UNAVAILABLE,
)
now = time.monotonic()
if now - _health_checked_at < _HEALTH_CACHE_SECONDS:
if _health_available:
return
raise onlyoffice_error(
"ONLYOFFICE_UNAVAILABLE",
"Office 预览服务暂不可用,请稍后重试",
status.HTTP_503_SERVICE_UNAVAILABLE,
)
async with _health_lock:
now = time.monotonic()
if now - _health_checked_at >= _HEALTH_CACHE_SECONDS:
available = False
try:
async with httpx.AsyncClient(timeout=2.0, follow_redirects=False) as client:
response = await client.get(f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/healthcheck")
available = response.status_code == status.HTTP_200_OK
except httpx.HTTPError:
available = False
_health_available = available
_health_checked_at = now
if not _health_available:
raise onlyoffice_error(
"ONLYOFFICE_UNAVAILABLE",
"Office 预览服务暂不可用,请稍后重试",
status.HTTP_503_SERVICE_UNAVAILABLE,
)
def build_preview_config(
*,
resource_type: OnlyOfficeResourceType,
resource_id: uuid.UUID,
file_name: str,
user_id: uuid.UUID,
user_name: str,
file_hash: str | None = None,
) -> OnlyOfficePreviewConfigRead:
format_info = office_format_for_filename(file_name)
if not format_info:
raise onlyoffice_error(
"ONLYOFFICE_FORMAT_UNSUPPORTED",
"该文件格式不支持 Office 在线预览",
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
)
file_type, document_type = format_info
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
config: dict[str, Any] = {
"type": "desktop",
"documentType": document_type,
"document": {
"fileType": file_type,
"key": onlyoffice_document_key(resource_type, resource_id, file_hash=file_hash),
"title": file_name,
"url": onlyoffice_content_url(resource_type, resource_id),
"permissions": {
"copy": False,
"comment": False,
"download": False,
"edit": False,
"fillForms": False,
"modifyContentControl": False,
"modifyFilter": False,
"print": False,
"review": False,
},
},
"editorConfig": {
"coEditing": {"mode": "strict", "change": False},
"customization": {
"chat": False,
"comments": False,
"forcesave": False,
},
"lang": "zh-CN",
"mode": "view",
"user": {"id": str(user_id), "name": user_name},
},
}
token_payload = {
**config,
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp()),
}
config["token"] = jwt.encode(
token_payload,
settings.ONLYOFFICE_JWT_SECRET or "",
algorithm="HS256",
)
return OnlyOfficePreviewConfigRead(
resource_type=resource_type,
resource_id=resource_id,
file_name=file_name,
expires_at=expires_at,
config=config,
)
def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any]:
if not token:
raise onlyoffice_error(
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
"无法验证 Office 文件请求",
status.HTTP_401_UNAUTHORIZED,
)
token = token.strip()
if " " in token:
scheme, credential = token.split(" ", 1)
if scheme.lower() != "bearer" or not credential.strip():
raise onlyoffice_error(
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
"无法验证 Office 文件请求",
status.HTTP_401_UNAUTHORIZED,
)
token = credential.strip()
try:
payload = decode_onlyoffice_token(token)
except JWTError as exc:
raise onlyoffice_error(
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
"无法验证 Office 文件请求",
status.HTTP_401_UNAUTHORIZED,
) from exc
request_payload = payload.get("payload")
token_url = request_payload.get("url") if isinstance(request_payload, dict) else None
if not isinstance(token_url, str) or not hmac.compare_digest(token_url, expected_url):
raise onlyoffice_error(
"ONLYOFFICE_SOURCE_URL_MISMATCH",
"Office 文件请求地址不匹配",
status.HTTP_401_UNAUTHORIZED,
)
return payload
def decode_onlyoffice_token(token: str) -> dict[str, Any]:
header = jwt.get_unverified_header(token)
if header.get("alg") != "HS256":
raise JWTError("unexpected algorithm")
return jwt.decode(
token,
settings.ONLYOFFICE_JWT_SECRET or "",
algorithms=["HS256"],
options={"verify_aud": False},
)
+106 -40
View File
@@ -13,6 +13,7 @@ import asyncio
import logging import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.permission_access_log import PermissionAccessLog from app.models.permission_access_log import PermissionAccessLog
@@ -22,85 +23,150 @@ logger = logging.getLogger("ctms.permission_log_writer")
BATCH_SIZE = 50 BATCH_SIZE = 50
FLUSH_INTERVAL = 5.0 FLUSH_INTERVAL = 5.0
QUEUE_MAX_SIZE = 10000 QUEUE_MAX_SIZE = 10000
WRITE_ATTEMPTS = 2
_QUEUE_STOP = object()
class PermissionLogWriter: class PermissionLogWriter:
def __init__(self): def __init__(self):
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE) self._queue: asyncio.Queue[dict | object] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._stopping = False
self._started_at: datetime | None = None
self._last_success_at: datetime | None = None
self._last_error_at: datetime | None = None
self._last_error_type: str | None = None
self._accepted_entries = 0
self._written_entries = 0
self._dropped_entries = 0
self._failed_batches = 0
self._failed_entries = 0
self._retry_count = 0
def enqueue(self, entry: dict) -> None: def enqueue(self, entry: dict) -> None:
if self._stopping:
self._dropped_entries += 1
logger.warning("Permission log writer is stopping, dropping entry")
return
try: try:
self._queue.put_nowait(entry) self._queue.put_nowait(entry)
self._accepted_entries += 1
except asyncio.QueueFull: except asyncio.QueueFull:
self._dropped_entries += 1
logger.warning("Permission log queue full, dropping entry") logger.warning("Permission log queue full, dropping entry")
async def start(self) -> None: async def start(self) -> None:
if self._task and not self._task.done():
return
self._stopping = False
self._started_at = datetime.now(timezone.utc)
self._task = asyncio.create_task(self._flush_loop()) self._task = asyncio.create_task(self._flush_loop())
logger.info("PermissionLogWriter started") logger.info("PermissionLogWriter started")
async def stop(self) -> None: async def stop(self) -> None:
if self._task: if self._task and not self._task.done():
self._task.cancel() self._stopping = True
try: await self._queue.put(_QUEUE_STOP)
await self._task await self._task
except asyncio.CancelledError: self._task = None
pass
await self._drain()
logger.info("PermissionLogWriter stopped") logger.info("PermissionLogWriter stopped")
async def _flush_loop(self) -> None: async def _flush_loop(self) -> None:
while True: while True:
batch = await self._collect_batch() batch, should_stop = await self._collect_batch()
if batch: if batch:
await self._write_batch(batch) await self._write_batch(batch)
if should_stop:
break
async def _collect_batch(self) -> list[dict]: async def _collect_batch(self) -> tuple[list[dict], bool]:
batch: list[dict] = [] batch: list[dict] = []
try: try:
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL) first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
if first is _QUEUE_STOP:
return batch, True
batch.append(first) batch.append(first)
except asyncio.TimeoutError: except asyncio.TimeoutError:
return batch return batch, False
while len(batch) < BATCH_SIZE: while len(batch) < BATCH_SIZE:
try: try:
item = self._queue.get_nowait() item = self._queue.get_nowait()
if item is _QUEUE_STOP:
return batch, True
batch.append(item) batch.append(item)
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
break break
return batch return batch, False
async def _write_batch(self, batch: list[dict]) -> None: async def _write_batch(self, batch: list[dict]) -> bool:
try: for attempt in range(WRITE_ATTEMPTS):
async with SessionLocal() as session:
for entry in batch:
log = PermissionAccessLog(
id=uuid.uuid4(),
study_id=entry["study_id"],
user_id=entry["user_id"],
endpoint_key=entry["endpoint_key"],
role=entry["role"],
allowed=entry["allowed"],
elapsed_ms=entry["elapsed_ms"],
ip_address=entry.get("ip_address"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
session.add(log)
await session.commit()
except Exception:
logger.exception("Failed to write permission access log batch (%d entries)", len(batch))
async def _drain(self) -> None:
batch: list[dict] = []
while not self._queue.empty():
try: try:
batch.append(self._queue.get_nowait()) async with SessionLocal() as session:
except asyncio.QueueEmpty: for entry in batch:
break log = PermissionAccessLog(
if batch: id=uuid.uuid4(),
await self._write_batch(batch) study_id=entry["study_id"],
user_id=entry["user_id"],
endpoint_key=entry["endpoint_key"],
role=entry["role"],
allowed=entry["allowed"],
elapsed_ms=entry["elapsed_ms"],
ip_address=entry.get("ip_address"),
user_agent=entry.get("user_agent"),
client_type=entry.get("client_type"),
client_version=entry.get("client_version"),
client_platform=entry.get("client_platform"),
build_channel=entry.get("build_channel"),
build_commit=entry.get("build_commit"),
request_headers=entry.get("request_headers"),
request_snapshot=entry.get("request_snapshot"),
request_id=entry.get("request_id"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
session.add(log)
await session.commit()
except Exception as exc:
self._last_error_at = datetime.now(timezone.utc)
self._last_error_type = type(exc).__name__
if attempt + 1 < WRITE_ATTEMPTS:
self._retry_count += 1
logger.warning(
"Permission access log batch write failed; retrying (%d entries)",
len(batch),
)
await asyncio.sleep(0)
continue
self._failed_batches += 1
self._failed_entries += len(batch)
logger.exception(
"Failed to write permission access log batch after retries (%d entries)",
len(batch),
)
return False
self._written_entries += len(batch)
self._last_success_at = datetime.now(timezone.utc)
return True
return False
def stats(self) -> dict[str, Any]:
return {
"running": bool(self._task and not self._task.done()),
"stopping": self._stopping,
"queue_size": self._queue.qsize(),
"queue_capacity": self._queue.maxsize,
"accepted_entries": self._accepted_entries,
"written_entries": self._written_entries,
"dropped_entries": self._dropped_entries,
"failed_batches": self._failed_batches,
"failed_entries": self._failed_entries,
"retry_count": self._retry_count,
"started_at": self._started_at.isoformat() if self._started_at else None,
"last_success_at": self._last_success_at.isoformat() if self._last_success_at else None,
"last_error_at": self._last_error_at.isoformat() if self._last_error_at else None,
"last_error_type": self._last_error_type,
}
_writer: PermissionLogWriter | None = None _writer: PermissionLogWriter | None = None
@@ -40,7 +40,7 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
from app.core.permission_monitor import get_permission_monitor from app.core.permission_monitor import get_permission_monitor
monitor = get_permission_monitor() monitor = get_permission_monitor()
cache_metrics = monitor.metrics.cache_metrics cache_metrics = monitor.metrics.cache_metrics.window_stats(3600)
snapshot = PermissionMetricSnapshot( snapshot = PermissionMetricSnapshot(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -50,8 +50,8 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
denied_checks=row.denied, denied_checks=row.denied,
avg_elapsed_ms=float(row.avg_ms), avg_elapsed_ms=float(row.avg_ms),
max_elapsed_ms=float(row.max_ms), max_elapsed_ms=float(row.max_ms),
cache_hits=cache_metrics.cache_hits, cache_hits=cache_metrics["cache_hits"],
cache_misses=cache_metrics.cache_misses, cache_misses=cache_metrics["cache_misses"],
error_count=0, error_count=0,
) )
session.add(snapshot) session.add(snapshot)
@@ -0,0 +1,541 @@
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 member as member_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
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:
overdue_aes, overdue_ae_due = await _count_and_earliest(
db,
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,
)
await notification_service.sync_aggregate_notification(
db,
study_id=study_id,
recipient_id=user.id,
category="RISK_OVERDUE_AE",
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=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, overdue_monitoring_due = await _count_and_earliest(
db,
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,
recipient_id=user.id,
category="RISK_OVERDUE_MONITORING",
priority="HIGH",
title="监查问题已逾期",
message=f"当前有 {overdue_monitoring} 条监查问题已超过计划解决日期",
action_path="/risk-issues/monitoring-visits",
source_type="RISK_OVERDUE_MONITORING",
source_id=str(study_id),
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,94 +6,170 @@ import asyncio
import logging import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.security_access_log import SecurityAccessLog from app.models.security_access_log import SecurityAccessLog
from app.services.ip_location import resolve_ip_location
from app.services.security_events import classify_security_event
logger = logging.getLogger("ctms.security_access_log_writer") logger = logging.getLogger("ctms.security_access_log_writer")
BATCH_SIZE = 100 BATCH_SIZE = 100
FLUSH_INTERVAL = 3.0 FLUSH_INTERVAL = 3.0
QUEUE_MAX_SIZE = 20000 QUEUE_MAX_SIZE = 20000
WRITE_ATTEMPTS = 2
_QUEUE_STOP = object()
class SecurityAccessLogWriter: class SecurityAccessLogWriter:
def __init__(self) -> None: def __init__(self) -> None:
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE) self._queue: asyncio.Queue[dict | object] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._stopping = False
self._started_at: datetime | None = None
self._last_success_at: datetime | None = None
self._last_error_at: datetime | None = None
self._last_error_type: str | None = None
self._accepted_entries = 0
self._written_entries = 0
self._dropped_entries = 0
self._failed_batches = 0
self._failed_entries = 0
self._retry_count = 0
def enqueue(self, entry: dict) -> None: def enqueue(self, entry: dict) -> None:
if self._stopping:
self._dropped_entries += 1
logger.warning("Security access log writer is stopping, dropping entry")
return
try: try:
self._queue.put_nowait(entry) self._queue.put_nowait(entry)
self._accepted_entries += 1
except asyncio.QueueFull: except asyncio.QueueFull:
self._dropped_entries += 1
logger.warning("Security access log queue full, dropping entry") logger.warning("Security access log queue full, dropping entry")
async def start(self) -> None: async def start(self) -> None:
if self._task and not self._task.done():
return
self._stopping = False
self._started_at = datetime.now(timezone.utc)
self._task = asyncio.create_task(self._flush_loop()) self._task = asyncio.create_task(self._flush_loop())
logger.info("SecurityAccessLogWriter started") logger.info("SecurityAccessLogWriter started")
async def stop(self) -> None: async def stop(self) -> None:
if self._task: if self._task and not self._task.done():
self._task.cancel() self._stopping = True
try: await self._queue.put(_QUEUE_STOP)
await self._task await self._task
except asyncio.CancelledError: self._task = None
pass
await self._drain()
logger.info("SecurityAccessLogWriter stopped") logger.info("SecurityAccessLogWriter stopped")
async def _flush_loop(self) -> None: async def _flush_loop(self) -> None:
while True: while True:
batch = await self._collect_batch() batch, should_stop = await self._collect_batch()
if batch: if batch:
await self._write_batch(batch) await self._write_batch(batch)
if should_stop:
break
async def _collect_batch(self) -> list[dict]: async def _collect_batch(self) -> tuple[list[dict], bool]:
batch: list[dict] = [] batch: list[dict] = []
try: try:
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL) first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
if first is _QUEUE_STOP:
return batch, True
batch.append(first) batch.append(first)
except asyncio.TimeoutError: except asyncio.TimeoutError:
return batch return batch, False
while len(batch) < BATCH_SIZE: while len(batch) < BATCH_SIZE:
try: try:
batch.append(self._queue.get_nowait()) item = self._queue.get_nowait()
if item is _QUEUE_STOP:
return batch, True
batch.append(item)
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
break break
return batch return batch, False
async def _write_batch(self, batch: list[dict]) -> None: async def _write_batch(self, batch: list[dict]) -> bool:
try: for attempt in range(WRITE_ATTEMPTS):
async with SessionLocal() as session: try:
for entry in batch: async with SessionLocal() as session:
session.add( for entry in batch:
SecurityAccessLog( classification = classify_security_event(
id=uuid.uuid4(),
method=entry["method"],
path=entry["path"], path=entry["path"],
status_code=entry["status_code"], status_code=entry["status_code"],
elapsed_ms=entry["elapsed_ms"],
client_ip=entry.get("client_ip"),
user_agent=entry.get("user_agent"),
auth_status=entry["auth_status"], auth_status=entry["auth_status"],
user_identifier=entry.get("user_identifier"), ip_location=resolve_ip_location(entry.get("client_ip")),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
) )
session.add(
SecurityAccessLog(
id=uuid.uuid4(),
method=entry["method"],
path=entry["path"],
status_code=entry["status_code"],
elapsed_ms=entry["elapsed_ms"],
client_ip=entry.get("client_ip"),
user_agent=entry.get("user_agent"),
client_type=entry.get("client_type"),
client_version=entry.get("client_version"),
client_platform=entry.get("client_platform"),
build_channel=entry.get("build_channel"),
build_commit=entry.get("build_commit"),
request_headers=entry.get("request_headers"),
request_snapshot=entry.get("request_snapshot"),
request_id=entry.get("request_id"),
category=classification["category"],
severity=classification["severity"],
auth_status=entry["auth_status"],
user_identifier=entry.get("user_identifier"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
)
await session.commit()
except Exception as exc:
self._last_error_at = datetime.now(timezone.utc)
self._last_error_type = type(exc).__name__
if attempt + 1 < WRITE_ATTEMPTS:
self._retry_count += 1
logger.warning(
"Security access log batch write failed; retrying (%d entries)",
len(batch),
) )
await session.commit() await asyncio.sleep(0)
except Exception: continue
logger.exception("Failed to write security access log batch (%d entries)", len(batch)) self._failed_batches += 1
self._failed_entries += len(batch)
logger.exception(
"Failed to write security access log batch after retries (%d entries)",
len(batch),
)
return False
self._written_entries += len(batch)
self._last_success_at = datetime.now(timezone.utc)
return True
return False
async def _drain(self) -> None: def stats(self) -> dict[str, Any]:
batch: list[dict] = [] return {
while not self._queue.empty(): "running": bool(self._task and not self._task.done()),
try: "stopping": self._stopping,
batch.append(self._queue.get_nowait()) "queue_size": self._queue.qsize(),
except asyncio.QueueEmpty: "queue_capacity": self._queue.maxsize,
break "accepted_entries": self._accepted_entries,
if batch: "written_entries": self._written_entries,
await self._write_batch(batch) "dropped_entries": self._dropped_entries,
"failed_batches": self._failed_batches,
"failed_entries": self._failed_entries,
"retry_count": self._retry_count,
"started_at": self._started_at.isoformat() if self._started_at else None,
"last_success_at": self._last_success_at.isoformat() if self._last_success_at else None,
"last_error_at": self._last_error_at.isoformat() if self._last_error_at else None,
"last_error_type": self._last_error_type,
}
_writer: SecurityAccessLogWriter | None = None _writer: SecurityAccessLogWriter | None = None
+37
View File
@@ -0,0 +1,37 @@
"""Security event classification shared by writers and monitoring APIs."""
from __future__ import annotations
from app.services.ip_location import IpLocation
SENSITIVE_PROBE_MARKERS = (
"/.env",
".env",
"/.git",
".git/config",
"backup",
"config.php",
"wp-config",
"database.yml",
)
def classify_security_event(
*,
path: str,
status_code: int,
auth_status: str,
ip_location: IpLocation | None = None,
) -> dict[str, str]:
normalized_path = (path or "").lower()
if any(marker in normalized_path for marker in SENSITIVE_PROBE_MARKERS):
return {"category": "PROBE", "severity": "CRITICAL"}
if status_code >= 500:
return {"category": "SERVER_ERROR", "severity": "HIGH"}
if auth_status == "INVALID_TOKEN":
return {"category": "INVALID_TOKEN", "severity": "MEDIUM"}
if auth_status == "ANONYMOUS" and status_code in {401, 403}:
return {"category": "ANONYMOUS_API", "severity": "MEDIUM"}
if status_code == 404:
return {"category": "NOT_FOUND_NOISE", "severity": "LOW"}
return {"category": "OTHER", "severity": "LOW"}

Some files were not shown because too many files have changed in this diff Show More