1 Commits

Author SHA1 Message Date
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
73 changed files with 1929 additions and 5619 deletions
@@ -40,8 +40,6 @@ 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
@@ -106,8 +104,6 @@ jobs:
desktop:
name: macOS Desktop
runs-on: macos-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults:
run:
working-directory: frontend
+30 -127
View File
@@ -19,35 +19,8 @@ concurrency:
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
name: Signed macOS release candidate
runs-on: macos-latest
defaults:
run:
@@ -55,13 +28,8 @@ jobs:
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' }}
REQUIRE_DESKTOP_SIGNING: "true"
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 }}
@@ -86,7 +54,7 @@ jobs:
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
echo "Desktop release candidates must run from a vX.Y.Z tag."
echo "Signed desktop release candidates must run from a vX.Y.Z tag."
exit 1
fi
expected_tag="v$(node -p "require('./package.json').version")"
@@ -109,7 +77,7 @@ jobs:
- name: Check release build metadata and signing environment
run: npm run release:env:check
- name: Check desktop release readiness
- name: Check signed desktop release readiness
run: npm run desktop:release-readiness:check
- name: Check synchronized client version
@@ -133,14 +101,9 @@ jobs:
- 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'
- name: Build signed and notarized Universal macOS artifacts
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: |
@@ -148,46 +111,28 @@ jobs:
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."
echo "Signed 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
spctl --assess --type execute --verbose=2 "${app}"
xcrun stapler validate "${app}"
xcrun stapler validate "${dmg}"
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
cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/"
- name: Upload macOS candidate artifacts
- name: Upload signed macOS candidate artifacts
uses: actions/upload-artifact@v4
with:
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
name: ctms-desktop-macos-${{ github.ref_name }}
path: frontend/src-tauri/target/desktop-release-macos/*
if-no-files-found: error
windows-release-candidate:
name: Windows release candidate
needs: release-policy
name: Signed Windows release candidate
runs-on: windows-latest
defaults:
run:
@@ -195,13 +140,8 @@ jobs:
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' }}
REQUIRE_WINDOWS_SIGNING: "true"
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 }}
@@ -223,7 +163,7 @@ jobs:
shell: pwsh
run: |
if ($env:GITHUB_REF_TYPE -ne "tag") {
throw "Desktop release candidates must run from a vX.Y.Z tag."
throw "Signed 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) {
@@ -242,7 +182,7 @@ jobs:
- name: Check release build metadata and signing environment
run: npm run release:env:check
- name: Check desktop release readiness
- name: Check signed desktop release readiness
run: npm run desktop:release-readiness:check
- name: Check synchronized client version
@@ -267,7 +207,6 @@ jobs:
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"
@@ -294,7 +233,6 @@ jobs:
Remove-Item $pfxPath -Force
- name: Write signed Windows Tauri config
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
shell: pwsh
run: |
$config = @{
@@ -311,16 +249,10 @@ jobs:
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
- name: Verify Authenticode and stage Windows artifacts
shell: pwsh
run: |
$bundleDir = "src-tauri/target/release/bundle/nsis"
@@ -338,27 +270,14 @@ jobs:
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)."
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)"
}
}
$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
}
Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage
- name: Remove Windows signing certificate
if: always()
@@ -370,17 +289,16 @@ jobs:
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
- name: Upload signed Windows candidate artifacts
uses: actions/upload-artifact@v4
with:
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
name: ctms-desktop-windows-${{ github.ref_name }}
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
@@ -398,21 +316,18 @@ jobs:
with:
node-version: "22.13"
- name: Download macOS candidate artifacts
- name: Download signed macOS candidate artifacts
uses: actions/download-artifact@v4
with:
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
name: ctms-desktop-macos-${{ github.ref_name }}
path: frontend/src-tauri/target/desktop-release-input/macos
- name: Download Windows candidate artifacts
- name: Download signed Windows candidate artifacts
uses: actions/download-artifact@v4
with:
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
name: ctms-desktop-windows-${{ github.ref_name }}
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: |
@@ -425,32 +340,20 @@ jobs:
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[@]}" \
--include "${dmgs[0]}" \
--include "${installers[0]}" \
--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
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
- 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 }}
name: ctms-desktop-release-${{ github.ref_name }}
path: frontend/src-tauri/target/desktop-release-feed/*
if-no-files-found: error
@@ -14,8 +14,6 @@ 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
+2 -4
View File
@@ -14,9 +14,7 @@
- 未完成后端 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`。该本地/分阶段例外不适用于后续版本。
- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建,使用组织 Windows 代码签名证书、RFC 3161 时间戳和 updater 私钥,并通过 Authenticode 与 updater feed 校验。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。
## 分支与发布治理
@@ -59,4 +57,4 @@ npm run desktop:build:app
本地缓存相关变更至少执行 `runtime:check`、`desktop:release:check`、`ui:contract`、`type-check`、`test:unit` 和 `build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
正式桌面发布构建必须使用组织批准的 updater 签名私钥,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 签名制品或验证证据。该例外不自动适用于后续版本。
正式桌面发布构建必须使用组织批准的 updater 签名私钥;macOS 必须完成 Apple 签名/公证,Windows 必须完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。未签名或 ad-hoc 构建只能作为内部验证构建描述。
@@ -1,38 +0,0 @@
"""Add global ledgers sharing the collaboration engine.
Revision ID: 20260903_01
Revises: 20260716_05
"""
import sqlalchemy as sa
from alembic import op
revision = "20260903_01"
down_revision = "20260716_05"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("collaboration_files", "study_id", nullable=True)
op.add_column("collaboration_files", sa.Column("scope", sa.String(16), nullable=False, server_default="PROJECT"))
op.add_column("collaboration_files", sa.Column("ledger_key", sa.String(64), nullable=True))
op.add_column("collaboration_files", sa.Column("description", sa.String(500), nullable=False, server_default=""))
op.create_unique_constraint("uq_collaboration_file_ledger_key", "collaboration_files", ["ledger_key"])
op.create_check_constraint(
"ck_collaboration_file_scope", "collaboration_files",
"(scope = 'PROJECT' AND study_id IS NOT NULL AND ledger_key IS NULL) OR "
"(scope = 'LEDGER' AND study_id IS NULL AND folder_id IS NULL AND ledger_key IS NOT NULL "
"AND file_type = 'cell' AND extension = 'xlsx' AND deleted_at IS NULL AND status IN ('ACTIVE', 'ARCHIVED'))",
)
def downgrade() -> None:
# Never discard retained ledger files, grants or history during rollback.
if op.get_bind().scalar(sa.text("SELECT count(*) FROM collaboration_files WHERE scope = 'LEDGER'")):
raise RuntimeError("已有全局台账,不能回滚台账迁移;请保留数据并使用前向修复")
op.drop_constraint("ck_collaboration_file_scope", "collaboration_files", type_="check")
op.drop_constraint("uq_collaboration_file_ledger_key", "collaboration_files", type_="unique")
op.drop_column("collaboration_files", "description")
op.drop_column("collaboration_files", "ledger_key")
op.drop_column("collaboration_files", "scope")
op.alter_column("collaboration_files", "study_id", nullable=False)
-201
View File
@@ -1,201 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles
from app.schemas.collaboration import (
CollaborationEditorConfigRead, CollaborationExportRecord, CollaborationRevisionRead,
CollaborationEditRequestRead, CollaborationEditRequestResolve, CollaborationOwnershipTransferRequest,
CollaborationShareLinkRead, CollaborationShareLinkUpdate, CollaborationRevisionUpdate,
)
from app.schemas.ledger import (
LedgerAccessSettings, LedgerCandidateRead, LedgerMemberRead, LedgerMemberUpsert, LedgerRead, LedgerRestore, LedgerSettings,
)
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import collaboration_service, collaboration_share_service, ledger_service, onlyoffice_collaboration_service, onlyoffice_service
def no_store(response: Response):
response.headers["Cache-Control"] = "no-store"
router = APIRouter(dependencies=[Depends(no_store)])
@router.get("", response_model=list[LedgerRead])
async def list_ledgers(db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
return await ledger_service.list_ledgers(db, user)
@router.post("/initialize", response_model=list[LedgerRead])
async def initialize(db: AsyncSession = Depends(get_db_session), user=Depends(require_roles(["ADMIN"]))):
return await ledger_service.initialize_ledgers(db, user)
@router.get("/{ledger_id}", response_model=LedgerRead)
async def read(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user)
return await ledger_service.read_ledger(db, item, user)
@router.patch("/{ledger_id}", response_model=LedgerRead)
async def update(ledger_id: uuid.UUID, payload: LedgerSettings, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.update_settings(db, item, payload, user)
@router.post("/{ledger_id}/import", response_model=LedgerRead)
async def import_template(
ledger_id: uuid.UUID, file: UploadFile = File(...), generation: int = Form(..., ge=1),
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.import_content(db, item, file, generation, user)
@router.get("/{ledger_id}/members", response_model=list[LedgerMemberRead])
async def members(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True)
return await ledger_service.list_members(db, item)
@router.get("/{ledger_id}/candidates", response_model=list[LedgerCandidateRead])
async def candidates(
ledger_id: uuid.UUID, keyword: str = Query(default="", max_length=100),
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
await ledger_service.get_ledger(db, ledger_id, user, manage=True)
return await ledger_service.candidates(db, keyword)
@router.put("/{ledger_id}/members", status_code=204)
async def grant(
ledger_id: uuid.UUID, payload: LedgerMemberUpsert,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
await ledger_service.set_member(db, item, payload, user)
@router.delete("/{ledger_id}/members/{user_id}", status_code=204)
async def revoke(
ledger_id: uuid.UUID, user_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
await ledger_service.remove_member(db, item, user_id, user)
@router.get("/{ledger_id}/editor-config", response_model=CollaborationEditorConfigRead)
async def editor_config(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
return await onlyoffice_collaboration_service.build_editor_config(db, item, user)
@router.patch("/{ledger_id}/access-settings", response_model=LedgerRead)
async def access_settings(ledger_id: uuid.UUID, payload: LedgerAccessSettings,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.update_access_settings(db, item, payload, user)
@router.get("/{ledger_id}/share-link", response_model=CollaborationShareLinkRead)
async def share_link(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await collaboration_share_service.get_share_link(db, item, user)
@router.put("/{ledger_id}/share-link", response_model=CollaborationShareLinkRead)
async def update_share_link(ledger_id: uuid.UUID, payload: CollaborationShareLinkUpdate,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await collaboration_share_service.update_share_link(db, item, payload, user)
@router.post("/{ledger_id}/edit-requests", response_model=CollaborationEditRequestRead)
async def request_edit(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
return await collaboration_service.create_edit_request(db, item, user)
@router.get("/{ledger_id}/edit-requests", response_model=list[CollaborationEditRequestRead])
async def edit_requests(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True)
return await collaboration_service.list_edit_requests(db, item, user)
@router.post("/{ledger_id}/edit-requests/{request_id}/resolve", response_model=CollaborationEditRequestRead)
async def resolve_request(ledger_id: uuid.UUID, request_id: uuid.UUID, payload: CollaborationEditRequestResolve,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, user)
@router.post("/{ledger_id}/transfer-ownership", response_model=LedgerRead)
async def transfer_owner(ledger_id: uuid.UUID, payload: CollaborationOwnershipTransferRequest,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
item = await collaboration_service.transfer_ownership(db, item, payload, user)
return await ledger_service.read_ledger(db, item, user)
@router.post("/{ledger_id}/downloads", status_code=204)
async def download_check(
ledger_id: uuid.UUID, payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user)
await collaboration_service.record_download(db, item, user, payload.file_type)
@router.get("/{ledger_id}/revisions", response_model=list[CollaborationRevisionRead])
async def revisions(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user)
return await collaboration_service.list_revisions(db, item)
@router.post("/{ledger_id}/revisions/{revision_id}/restore", response_model=CollaborationRevisionRead)
async def restore(
ledger_id: uuid.UUID, revision_id: uuid.UUID, payload: LedgerRestore,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.restore_revision(db, item, revision_id, payload.generation, user)
@router.patch("/{ledger_id}/revisions/{revision_id}", response_model=CollaborationRevisionRead)
async def name_revision(
ledger_id: uuid.UUID, revision_id: uuid.UUID, payload: CollaborationRevisionUpdate,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
return await collaboration_service.update_revision(db, item, revision_id, payload, user)
@router.get("/{ledger_id}/revisions/{revision_id}/download")
async def download_revision(
ledger_id: uuid.UUID, revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
await collaboration_service.require_file_exporter(db, item, user)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, user)
await collaboration_service.record_download(db, item, user, "xlsx")
return FileResponse(revision.file_uri, filename=item.title, media_type=revision.mime_type,
headers={"Cache-Control": "no-store"})
@router.get("/{ledger_id}/revisions/{revision_id}/preview-config", response_model=OnlyOfficePreviewConfigRead)
async def preview(
ledger_id: uuid.UUID, revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, user)
await onlyoffice_service.ensure_onlyoffice_available()
return onlyoffice_service.build_preview_config(
resource_type="collaboration_revision", resource_id=revision.id, file_name=item.title,
file_hash=revision.file_hash, user_id=user.id, user_name=user.full_name,
)
-2
View File
@@ -1,5 +1,4 @@
from fastapi import APIRouter
from app.api.v1 import ledgers
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
@@ -33,7 +32,6 @@ api_router.include_router(startup.router, prefix="/studies/{study_id}/startup",
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(ledgers.router, prefix="/ledgers", tags=["ledgers"])
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_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
+2 -12
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
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
@@ -35,22 +35,12 @@ class CollaborationFolder(Base):
class CollaborationFile(Base):
__tablename__ = "collaboration_files"
__table_args__ = (
CheckConstraint(
"(scope = 'PROJECT' AND study_id IS NOT NULL AND ledger_key IS NULL) OR "
"(scope = 'LEDGER' AND study_id IS NULL AND folder_id IS NULL AND ledger_key IS NOT NULL "
"AND file_type = 'cell' AND extension = 'xlsx' AND deleted_at IS NULL AND status IN ('ACTIVE', 'ARCHIVED'))",
name="ck_collaboration_file_scope",
),
UniqueConstraint("ledger_key", name="uq_collaboration_file_ledger_key"),
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[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="PROJECT", server_default="PROJECT")
ledger_key: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
description: Mapped[str] = mapped_column(String(500), nullable=False, default="", server_default="")
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
)
-88
View File
@@ -1,88 +0,0 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
LedgerRole = Literal["VIEWER", "EDITOR", "MANAGER"]
class LedgerRead(BaseModel):
id: uuid.UUID
title: str
description: str
status: Literal["ACTIVE", "ARCHIVED"]
owner_id: uuid.UUID
owner_name: str
generation: int
updated_at: datetime
current_revision_id: uuid.UUID | None
current_revision_no: int | None
current_revision_file_size: int | None
current_revision_mime_type: str | None
current_revision_created_at: datetime | None
role: LedgerRole
can_edit: bool
can_manage: bool
can_export: bool
allow_export: bool
allow_edit_request: bool
allow_sheet_structure_edit: bool
file_type: Literal["cell"] = "cell"
extension: Literal["xlsx"] = "xlsx"
can_request_edit: bool
edit_request_status: Literal["PENDING", "APPROVED", "REJECTED"] | None = None
can_transfer_ownership: bool
class LedgerSettings(BaseModel):
title: str = Field(min_length=1, max_length=240)
description: str = Field(default="", max_length=500)
status: Literal["ACTIVE", "ARCHIVED"]
owner_id: uuid.UUID
allow_export: bool = False
allow_edit_request: bool | None = None
allow_sheet_structure_edit: bool = False
generation: int = Field(ge=1)
@field_validator("title")
@classmethod
def trim_title(cls, value):
value = value.strip()
if not value:
raise ValueError("台账名称不能为空")
return value
class LedgerMemberUpsert(BaseModel):
user_id: uuid.UUID
role: LedgerRole
class LedgerMemberRead(BaseModel):
user_id: uuid.UUID
full_name: str
email: str
role: LedgerRole
is_active: bool
protected_label: str | None = None
class LedgerCandidateRead(BaseModel):
user_id: uuid.UUID
full_name: str
email: str
role_in_study: str
can_be_editor: bool = True
can_be_manager: bool = True
class LedgerAccessSettings(BaseModel):
generation: int = Field(ge=1)
allow_export: bool | None = None
allow_edit_request: bool | None = None
allow_sheet_structure_edit: bool | None = None
class LedgerRestore(BaseModel):
generation: int = Field(ge=1)
+69 -121
View File
@@ -46,7 +46,6 @@ from app.schemas.collaboration import (
CollaborationRevisionUpdate,
)
from app.services import notification_service
from app.services import ledger_access
COLLABORATION_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "collaboration"
FILE_TYPE_EXTENSION = {"word": "docx", "cell": "xlsx", "slide": "pptx"}
@@ -333,8 +332,6 @@ async def _require_role_assignable(
async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_edit(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -345,8 +342,6 @@ async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool
async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_manage(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -356,8 +351,6 @@ async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bo
async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_export(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -369,8 +362,6 @@ async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bo
async def can_create_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return False
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -396,12 +387,6 @@ async def edit_request_status(
async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return (
item.status == "ACTIVE" and item.allow_edit_request
and await ledger_access.role_for(db, item, user) == "VIEWER"
and await edit_request_status(db, item, user.id) != "PENDING"
)
if not item.allow_edit_request or await can_edit_file(db, item, user):
return False
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -413,8 +398,6 @@ async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user)
async def can_transfer_ownership(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return bool(user and user.is_active and (is_system_admin(user) or item.owner_id == user.id))
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -552,7 +535,7 @@ async def _persist_revision_bytes(
select(func.max(CollaborationRevision.revision_no)).where(CollaborationRevision.file_id == locked.id)
)
revision_no = int(last_no or 0) + 1
directory = COLLABORATION_ROOT / ("ledgers" if ledger_access.is_ledger(locked) else str(locked.study_id)) / str(locked.id)
directory = COLLABORATION_ROOT / str(locked.study_id) / str(locked.id)
directory.mkdir(parents=True, exist_ok=True)
destination = directory / f"{uuid.uuid4()}.{locked.extension}"
async with aiofiles.open(destination, "wb") as stream:
@@ -858,8 +841,6 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None:
if ledger_access.is_ledger(item):
raise HTTPException(403, "台账禁止删除,请使用归档")
await require_file_manager(db, item, user)
item.status = "DELETED"
item.deleted_at = datetime.now(timezone.utc)
@@ -918,8 +899,6 @@ async def delete_revision(
revision_id: uuid.UUID,
user,
) -> None:
if ledger_access.is_ledger(item):
raise HTTPException(403, "台账历史版本禁止删除")
await require_file_manager(db, item, user)
revision = await get_revision_or_404(db, item, revision_id)
if item.current_revision_id == revision.id:
@@ -986,19 +965,10 @@ async def copy_revision(
async def restore_revision(
db: AsyncSession, item: CollaborationFile, revision_id: uuid.UUID, user, change_summary: str | None
) -> CollaborationRevision:
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user, manage=True)
await require_file_editor(db, item, user)
revision = await get_revision_or_404(db, item, revision_id)
async with aiofiles.open(revision.file_uri, "rb") as stream:
content = await stream.read()
if ledger_access.is_ledger(item):
content, _ = apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
# A historical revision may still carry CTMS protection after the
# current ledger was unlocked. Apply today's policy on restoration.
protection_backup=item.sheet_structure_protection_backup or "",
)
restored, created = await append_revision(
db,
item,
@@ -1122,14 +1092,9 @@ async def create_edit_request(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="您已拥有此文件的编辑权限")
if not item.allow_edit_request:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="此文件未开放编辑权限申请")
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user)
if item.status != "ACTIVE":
raise HTTPException(409, "已归档台账不能申请编辑权限")
else:
membership = await member_crud.get_member(db, item.study_id, user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前项目成员可以申请编辑权限")
membership = await member_crud.get_member(db, item.study_id, user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前项目成员可以申请编辑权限")
pending = await db.scalar(
select(CollaborationEditRequest).where(
CollaborationEditRequest.file_id == item.id,
@@ -1142,41 +1107,40 @@ async def create_edit_request(
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
db.add(request)
await db.flush()
if not ledger_access.is_ledger(item):
manager_ids = set((await db.scalars(
select(StudyMember.user_id)
.outerjoin(
CollaborationMember,
and_(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == StudyMember.user_id,
),
)
.where(
StudyMember.study_id == item.study_id,
StudyMember.is_active.is_(True),
or_(
StudyMember.user_id == item.owner_id,
CollaborationMember.role == "MANAGER",
),
)
)).all())
manager_ids.add(item.owner_id)
requester_name = str(user.full_name or user.email or "项目成员")
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=manager_ids,
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=str(request.id),
dedupe_key=f"collaboration-edit-request:{request.id}",
source_version="PENDING",
manager_ids = set((await db.scalars(
select(StudyMember.user_id)
.outerjoin(
CollaborationMember,
and_(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == StudyMember.user_id,
),
)
.where(
StudyMember.study_id == item.study_id,
StudyMember.is_active.is_(True),
or_(
StudyMember.user_id == item.owner_id,
CollaborationMember.role == "MANAGER",
),
)
)).all())
manager_ids.add(item.owner_id)
requester_name = str(user.full_name or user.email or "项目成员")
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=manager_ids,
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=str(request.id),
dedupe_key=f"collaboration-edit-request:{request.id}",
source_version="PENDING",
)
await db.commit()
await db.refresh(request)
return _edit_request_read(request, user)
@@ -1215,18 +1179,11 @@ async def resolve_edit_request(
if request.status != "PENDING":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="编辑权限申请已处理")
requester = await db.get(User, request.requester_id)
if ledger_access.is_ledger(item):
if item.status != "ACTIVE" or not await ledger_access.role_for(db, item, requester):
raise HTTPException(422, "申请人已无台账访问权限或台账已归档")
else:
membership = await member_crud.get_member(db, item.study_id, request.requester_id)
if not requester or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="申请人已不是当前项目的有效成员")
membership = await member_crud.get_member(db, item.study_id, request.requester_id)
if not requester or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="申请人已不是当前项目的有效成员")
if payload.status == "APPROVED":
if ledger_access.is_ledger(item):
item.generation += 1
else:
await _require_role_assignable(db, item.study_id, requester, membership, "EDITOR")
await _require_role_assignable(db, item.study_id, requester, membership, "EDITOR")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == request.requester_id,
@@ -1244,31 +1201,30 @@ async def resolve_edit_request(
request.status = payload.status
request.resolved_by = user.id
request.resolved_at = datetime.now(timezone.utc)
if not ledger_access.is_ledger(item):
approved = payload.status == "APPROVED"
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=[request.requester_id],
category="COLLABORATION_EDIT_REQUEST_RESULT",
priority="NORMAL",
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
action_path=(
f"/knowledge/collaboration/{item.id}"
if approved
else "/knowledge/collaboration"
),
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request-result:{request.id}",
requires_action=False,
)
await notification_service.resolve_source_notifications(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
approved = payload.status == "APPROVED"
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=[request.requester_id],
category="COLLABORATION_EDIT_REQUEST_RESULT",
priority="NORMAL",
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
action_path=(
f"/knowledge/collaboration/{item.id}"
if approved
else "/knowledge/collaboration"
),
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request-result:{request.id}",
requires_action=False,
)
await notification_service.resolve_source_notifications(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
await db.commit()
await db.refresh(request)
return _edit_request_read(request, requester)
@@ -1286,16 +1242,11 @@ async def transfer_ownership(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
if payload.new_owner_id == locked.owner_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="所选联系人已经是文档所有者")
membership = await member_crud.get_member(db, locked.study_id, payload.new_owner_id)
target = await db.get(User, payload.new_owner_id)
if ledger_access.is_ledger(locked):
if not target or not target.is_active:
raise HTTPException(422, "只能转让给有效的系统账号")
locked.generation += 1
else:
membership = await member_crud.get_member(db, locked.study_id, payload.new_owner_id)
if not target or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能转让给当前项目的有效成员")
await _require_role_assignable(db, locked.study_id, target, membership, "MANAGER")
if not target or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能转让给当前项目的有效成员")
await _require_role_assignable(db, locked.study_id, target, membership, "MANAGER")
previous_owner_id = locked.owner_id
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == locked.id,
@@ -1317,9 +1268,6 @@ async def transfer_ownership(
))
if previous_owner_member:
previous_owner_member.role = "MANAGER"
elif ledger_access.is_ledger(locked):
db.add(CollaborationMember(file_id=locked.id, user_id=previous_owner_id,
role="MANAGER", invited_by=user.id))
locked.owner_id = payload.new_owner_id
locked.updated_at = datetime.now(timezone.utc)
await db.commit()
@@ -22,7 +22,7 @@ from app.schemas.collaboration import (
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
)
from app.services import collaboration_service, ledger_access
from app.services import collaboration_service
SHARE_PATH = "/collaboration/share"
@@ -177,11 +177,6 @@ async def update_share_link(
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
if ledger_access.is_ledger(item):
# Ledger routes hold the same file lock as callbacks before changing a link.
item.generation += 1
item.updated_at = now
link.token_version += 1
await db.commit()
await db.refresh(link)
return share_link_read(link)
-41
View File
@@ -1,41 +0,0 @@
"""Account-level ledger permissions, independent of study membership."""
from sqlalchemy import select
from fastapi import HTTPException
from app.core.deps import is_system_admin
from app.models.collaboration import CollaborationMember
def is_ledger(item) -> bool:
return getattr(item, "scope", "PROJECT") == "LEDGER"
async def role_for(db, item, user) -> str | None:
if not user or not user.is_active:
return None
if is_system_admin(user) or item.owner_id == user.id:
return "MANAGER"
return await db.scalar(select(CollaborationMember.role).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user.id,
))
async def can_edit(db, item, user) -> bool:
return item.status == "ACTIVE" and await role_for(db, item, user) in {"EDITOR", "MANAGER"}
async def can_manage(db, item, user) -> bool:
return await role_for(db, item, user) == "MANAGER"
async def can_export(db, item, user) -> bool:
role = await role_for(db, item, user)
return role == "MANAGER" or (role in {"EDITOR", "VIEWER"} and item.allow_export)
async def require_access(db, item, user, *, manage=False):
role = await role_for(db, item, user)
if role not in {"VIEWER", "EDITOR", "MANAGER"}:
raise HTTPException(404, "台账不存在或未获授权")
if manage and role != "MANAGER":
raise HTTPException(403, "仅台账管理人员可以执行此操作")
-259
View File
@@ -1,259 +0,0 @@
"""Global ledger metadata and grants; reuse collaboration revisions and sessions."""
import io
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
import aiofiles
from fastapi import HTTPException
from sqlalchemy import or_, select, text
from app.core.config import settings
from app.core.deps import is_system_admin
from app.models.collaboration import CollaborationFile, CollaborationMember, CollaborationRevision
from app.models.user import User, UserStatus
from app.schemas.ledger import LedgerRead, LedgerSettings
from app.services import collaboration_service as collaboration
from app.services import ledger_access
LEDGER_TEMPLATES = (
("ledger-1", "医学事务部合同台账明细表.xlsx", "contract-ledger.xlsx", "记录合同编号、项目代码与合同内容"),
("ledger-2", "医学事务部临床运营项目编号.xlsx", "clinical-project-register.xlsx", "记录项目编号、产品信息与生效日期"),
)
TEMPLATE_ROOT = Path(__file__).resolve().parents[1] / "templates" / "ledgers"
async def get_ledger(db, ledger_id, user, *, manage=False, lock=False):
stmt = select(CollaborationFile).where(
CollaborationFile.id == ledger_id, CollaborationFile.scope == "LEDGER",
).execution_options(populate_existing=True)
if lock:
stmt = stmt.with_for_update()
item = await db.scalar(stmt)
if not item:
raise HTTPException(404, "台账不存在或未获授权")
await ledger_access.require_access(db, item, user, manage=manage)
return item
async def read_ledger(db, item, user):
role = await ledger_access.role_for(db, item, user)
await ledger_access.require_access(db, item, user)
owner = await db.get(User, item.owner_id)
revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None
return LedgerRead(
id=item.id, title=item.title, description=item.description, status=item.status,
owner_id=item.owner_id, owner_name=owner.full_name if owner else "",
generation=item.generation, updated_at=item.updated_at,
current_revision_id=item.current_revision_id,
current_revision_no=revision.revision_no if revision else None,
current_revision_file_size=revision.file_size if revision else None,
current_revision_mime_type=revision.mime_type if revision else None,
current_revision_created_at=revision.created_at if revision else None,
role=role, can_edit=await ledger_access.can_edit(db, item, user),
can_manage=role == "MANAGER", can_export=await ledger_access.can_export(db, item, user),
allow_export=item.allow_export, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
allow_edit_request=item.allow_edit_request,
can_request_edit=await collaboration.can_request_edit_file(db, item, user),
edit_request_status=await collaboration.edit_request_status(db, item, user.id),
can_transfer_ownership=await collaboration.can_transfer_ownership(db, item, user),
)
async def list_ledgers(db, user):
stmt = select(CollaborationFile).where(CollaborationFile.scope == "LEDGER")
if not is_system_admin(user):
granted = select(CollaborationMember.file_id).where(
CollaborationMember.user_id == user.id,
CollaborationMember.role.in_(["VIEWER", "EDITOR", "MANAGER"]),
)
stmt = stmt.where(or_(CollaborationFile.owner_id == user.id, CollaborationFile.id.in_(granted)))
rows = (await db.scalars(stmt.order_by(CollaborationFile.ledger_key))).all()
return [await read_ledger(db, item, user) for item in rows]
async def initialize_ledgers(db, user):
if not is_system_admin(user):
raise HTTPException(403, "仅系统管理员可以初始化台账")
# Serialize initialization across administrators; unique keys also prevent duplicates.
if db.get_bind().dialect.name == "postgresql":
await db.execute(text("SELECT pg_advisory_xact_lock(2026090301)"))
for key, title, template_name, description in LEDGER_TEMPLATES:
if await db.scalar(select(CollaborationFile.id).where(CollaborationFile.ledger_key == key)):
continue
item = CollaborationFile(
id=uuid.uuid4(), scope="LEDGER", ledger_key=key, study_id=None, folder_id=None,
title=title, description=description, file_type="cell", extension="xlsx", owner_id=user.id,
status="ACTIVE", generation=1, allow_sheet_structure_edit=False, allow_export=False,
)
db.add(item)
await db.flush()
content, backup = collaboration.apply_workbook_structure_policy(
(TEMPLATE_ROOT / template_name).read_bytes(), file_id=item.id,
allow_sheet_structure_edit=False, protection_backup=None,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="CREATE", created_by=user.id)
await db.commit()
return await list_ledgers(db, user)
def check_generation(item, generation):
if item.generation != generation:
raise HTTPException(409, "台账已发生变化,请刷新后重试")
def advance_generation(item):
item.generation += 1
item.updated_at = datetime.now(timezone.utc)
async def active_user(db, user_id):
user = await db.get(User, user_id)
if not user or not user.is_active:
raise HTTPException(422, "请选择有效的系统账号")
return user
async def update_settings(db, item, payload, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, payload.generation)
if payload.owner_id != item.owner_id:
if not is_system_admin(user):
raise HTTPException(403, "仅系统管理员可以变更台账负责人")
await active_user(db, payload.owner_id)
if payload.allow_sheet_structure_edit != item.allow_sheet_structure_edit:
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).is_file():
raise HTTPException(404, "台账内容不存在")
async with aiofiles.open(revision.file_uri, "rb") as stream:
content = await stream.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
content, backup = collaboration.apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=payload.allow_sheet_structure_edit,
protection_backup=item.sheet_structure_protection_backup,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="PERMISSION_CHANGE", created_by=user.id)
item.title = collaboration._safe_title(payload.title, "xlsx")
item.description = payload.description.strip()
item.status = payload.status
item.owner_id = payload.owner_id
item.allow_export = payload.allow_export
if payload.allow_edit_request is not None:
item.allow_edit_request = payload.allow_edit_request
item.allow_sheet_structure_edit = payload.allow_sheet_structure_edit
advance_generation(item)
await db.commit()
return await read_ledger(db, item, user)
async def update_access_settings(db, item, payload, user):
values = dict(title=item.title, description=item.description, status=item.status,
owner_id=item.owner_id, allow_export=item.allow_export,
allow_edit_request=item.allow_edit_request,
allow_sheet_structure_edit=item.allow_sheet_structure_edit)
values.update(payload.model_dump(exclude_none=True))
return await update_settings(db, item, LedgerSettings(**values), user)
async def list_members(db, item):
rows = (await db.execute(select(CollaborationMember, User).join(
User, User.id == CollaborationMember.user_id,
).where(CollaborationMember.file_id == item.id).order_by(User.full_name))).all()
grants = {user.id: dict(user_id=user.id, full_name=user.full_name, email=user.email,
role=member.role, is_active=user.is_active) for member, user in rows}
implicit = (await db.scalars(select(User).where(or_(
User.id == item.owner_id,
(User.is_admin.is_(True)) & (User.status == UserStatus.ACTIVE),
)).order_by(User.full_name))).all()
for user in implicit:
grants[user.id] = dict(user_id=user.id, full_name=user.full_name, email=user.email,
role="MANAGER", is_active=user.is_active,
protected_label="所有者" if user.id == item.owner_id else "系统管理员")
return sorted(grants.values(), key=lambda member: (
member["user_id"] != item.owner_id, member["role"] != "MANAGER", member["full_name"],
))
async def set_member(db, item, payload, user):
await ledger_access.require_access(db, item, user, manage=True)
target = await active_user(db, payload.user_id)
if target.id == item.owner_id or is_system_admin(target):
raise HTTPException(422, "负责人和系统管理员已拥有管理权限")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == target.id,
))
if member and member.role == payload.role:
return
if member:
member.role = payload.role
else:
db.add(CollaborationMember(file_id=item.id, user_id=target.id, role=payload.role, invited_by=user.id))
advance_generation(item)
await db.commit()
async def remove_member(db, item, user_id, user):
await ledger_access.require_access(db, item, user, manage=True)
if user_id == item.owner_id:
raise HTTPException(422, "不能移除台账负责人")
target = await db.get(User, user_id)
if target and is_system_admin(target):
raise HTTPException(422, "系统管理员始终拥有管理权限")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user_id,
))
if member:
await db.delete(member)
advance_generation(item)
await db.commit()
async def candidates(db, keyword):
stmt = select(User).where(User.status == UserStatus.ACTIVE)
if keyword:
pattern = f"%{keyword}%"
stmt = stmt.where(or_(User.full_name.ilike(pattern), User.email.ilike(pattern)))
rows = (await db.scalars(stmt.order_by(User.full_name, User.id))).all()
return [dict(user_id=user.id, full_name=user.full_name, email=user.email,
role_in_study="系统管理员" if user.is_admin else "系统账号") for user in rows]
async def import_content(db, item, upload, generation, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, generation)
if item.status != "ACTIVE":
raise HTTPException(409, "请先启用台账再导入")
if Path(upload.filename or "").suffix.lower() != ".xlsx":
raise HTTPException(415, "台账仅支持 XLSX 文件")
content = await upload.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
raise HTTPException(413, "台账文件为空或超出大小限制")
try:
with zipfile.ZipFile(io.BytesIO(content)) as package:
if "xl/workbook.xml" not in package.namelist():
raise ValueError()
if sum(info.file_size for info in package.infolist()) > settings.COLLABORATION_MAX_FILE_BYTES * 20:
raise ValueError()
if any("vbaproject" in name.lower() for name in package.namelist()):
raise ValueError()
except (zipfile.BadZipFile, ValueError):
raise HTTPException(422, "XLSX 文件无效、包含宏或解压后过大")
content, backup = collaboration.apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
protection_backup=None,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="IMPORT", created_by=user.id)
advance_generation(item)
await db.commit()
return await read_ledger(db, item, user)
async def restore_revision(db, item, revision_id, generation, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, generation)
if item.status != "ACTIVE":
raise HTTPException(409, "请先启用台账再恢复版本")
return await collaboration.restore_revision(db, item, revision_id, user, None)
@@ -2,10 +2,8 @@ from __future__ import annotations
import hashlib
import hmac
import io
import json
import uuid
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
@@ -27,7 +25,7 @@ from app.models.collaboration import (
)
from app.models.user import User
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
from app.services import collaboration_service, ledger_access, onlyoffice_service
from app.services import collaboration_service, onlyoffice_service
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
@@ -59,55 +57,12 @@ async def _active_session(
).order_by(CollaborationSession.created_at.desc())
)
if session:
should_recover = session.status == "ERROR"
if session.status == "ACTIVE":
created_at = session.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
if (
session.last_callback_at is None
and datetime.now(timezone.utc) - created_at < timedelta(seconds=15)
):
# The editor config can be requested twice before the first
# browser has connected and emitted status 1. Keep a short
# connection grace period so the second request does not retire
# the freshly issued key as a false stale session.
return session
live_users = await _document_server_users(session.document_key)
if live_users:
return session
# A service restart or rejected final callback can leave the row
# ACTIVE after Document Server has already retired the editing
# process. Reusing that key opens its forgotten copy as an
# unbound server backup, so retire it exactly like a final callback.
should_recover = True
# A final callback ends the editing lifecycle for this key. Never
# reactivate it: Document Server can retain a cached or forgotten copy
# for the old key, especially across a container restart.
recovered = (
await _recover_forgotten_content(session.document_key, item.file_type)
if should_recover
else None
)
if recovered is not None:
revision, _ = await collaboration_service.append_revision(
db,
item,
recovered,
source="SERVER_RECOVERY",
created_by=user_id,
change_summary="自动恢复在线文档服务器备份",
)
session.base_revision_id = revision.id
# The retired key can still point to Document Server's cache. A new
# generation must use a new key or a later open can fall back to the
# same server-side copy again.
item.generation += 1
session.status = "RECOVERED" if recovered is not None else "CLOSED"
session.closed_at = datetime.now(timezone.utc)
await db.commit()
session = None
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(
@@ -126,8 +81,6 @@ async def _active_session(
async def build_editor_config(
db: AsyncSession, item: CollaborationFile, user
) -> CollaborationEditorConfigRead:
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user)
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():
@@ -205,15 +158,6 @@ async def build_shared_editor_config(
client_id: str,
display_name: str,
) -> CollaborationEditorConfigRead:
if ledger_access.is_ledger(item):
version = link.token_version
item = await db.scalar(select(CollaborationFile).where(
CollaborationFile.id == item.id,
).with_for_update().execution_options(populate_existing=True))
await db.refresh(link)
if (not item or item.status != "ACTIVE" or not link.enabled or link.token_version != version
or (link.expires_at and link.expires_at <= datetime.now(timezone.utc))):
raise HTTPException(404, "共享链接不存在或已失效")
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():
@@ -225,10 +169,6 @@ async def build_shared_editor_config(
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]}"
if ledger_access.is_ledger(item):
known_users = json.loads(session.active_users or "[]")
session.active_users = json.dumps(list(dict.fromkeys([*known_users, external_user_id])))
await db.commit()
config: dict[str, Any] = {
"type": "desktop",
"documentType": item.file_type,
@@ -294,8 +234,6 @@ async def get_session_content(
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="协作文件内容不存在")
if ledger_access.is_ledger(item) and session.generation != item.generation:
raise HTTPException(403, "台账会话已失效,请重新打开")
return revision, item
@@ -384,123 +322,11 @@ async def _download_result(url: str) -> bytes:
return bytes(content)
def _validate_recovered_content(content: bytes, file_type: str) -> None:
required_part = {
"word": "word/document.xml",
"cell": "xl/workbook.xml",
"slide": "ppt/presentation.xml",
}.get(file_type)
if not required_part or not zipfile.is_zipfile(io.BytesIO(content)):
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份格式无效")
try:
with zipfile.ZipFile(io.BytesIO(content)) as package:
if required_part not in package.namelist() or package.testzip() is not None:
raise ValueError
except (zipfile.BadZipFile, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份已损坏") from exc
async def _recover_forgotten_content(document_key: str, file_type: str) -> bytes | None:
"""Download a Document Server backup left behind by a failed final save."""
command = {"c": "getForgotten", "key": document_key}
token = jwt.encode(command, settings.ONLYOFFICE_JWT_SECRET or "", algorithm="HS256")
command_url = f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/command"
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
response = await client.post(
command_url,
params={"shardkey": document_key},
json={**command, "token": token},
)
if response.status_code != status.HTTP_200_OK:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用")
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用") from exc
error = payload.get("error") if isinstance(payload, dict) else None
if error == 1:
# Document Server no longer has a forgotten copy. Starting from the
# last confirmed CTMS revision is then the only recoverable state.
return None
if (
error != 0
or payload.get("key") != document_key
or not isinstance(payload.get("url"), str)
or not payload["url"]
):
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器返回的备份信息无效")
content = await _download_result(payload["url"])
_validate_recovered_content(content, file_type)
return content
async def _document_server_users(document_key: str) -> list[str]:
"""Return live editor ids for a key without trusting stale database state."""
command = {"c": "info", "key": document_key}
token = jwt.encode(command, settings.ONLYOFFICE_JWT_SECRET or "", algorithm="HS256")
command_url = f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/command"
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
response = await client.post(
command_url,
params={"shardkey": document_key},
json={**command, "token": token},
)
if response.status_code != status.HTTP_200_OK:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="在线文档会话检查服务暂不可用",
)
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="在线文档会话检查服务暂不可用",
) from exc
error = payload.get("error") if isinstance(payload, dict) else None
if error == 1:
# The database can retain an ACTIVE row after an interrupted callback,
# while Document Server no longer has a live editing process for it.
return []
users = payload.get("users") if isinstance(payload, dict) else None
if (
error != 0
or payload.get("key") != document_key
or not isinstance(users, list)
or any(not isinstance(user_id, str) or not user_id for user_id in users)
):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="在线文档服务器返回的会话信息无效",
)
return list(dict.fromkeys(users))
async def list_live_editing_sessions(db: AsyncSession) -> list[tuple[str, int]]:
"""List file titles and live editor counts for deployment safety checks."""
rows = (
await db.execute(
select(CollaborationSession.document_key, CollaborationFile.title)
.join(CollaborationFile, CollaborationFile.id == CollaborationSession.file_id)
.where(CollaborationSession.status == "ACTIVE")
.order_by(CollaborationFile.title)
)
).all()
active: list[tuple[str, int]] = []
for document_key, title in rows:
users = await _document_server_users(document_key)
if users:
active.append((title, len(users)))
return active
async def _callback_user(
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
) -> User | None:
has_public_share_user = False
for value in payload.users or json.loads(session.active_users or "[]"):
for value in payload.users:
if value.startswith("share-"):
has_public_share_user = True
continue
@@ -518,40 +344,13 @@ async def _callback_user(
return user
async def _ledger_callback_can_edit(db, item, payload, session):
users = payload.users or json.loads(session.active_users or "[]")
if not users:
return await ledger_access.can_edit(db, item, await _callback_user(db, payload, session))
for value in users:
try:
actor = await db.get(User, uuid.UUID(value))
except (ValueError, TypeError):
continue
if await ledger_access.can_edit(db, item, actor):
return True
link = await db.scalar(select(CollaborationShareLink).where(
CollaborationShareLink.file_id == item.id,
CollaborationShareLink.enabled.is_(True),
CollaborationShareLink.access_mode == "EDIT",
))
if not link or (link.expires_at and link.expires_at <= datetime.now(timezone.utc)):
return False
prefix = f"share-{link.id.hex[:12]}-"
return any(value.startswith(prefix) for value in users)
async def process_callback(
db: AsyncSession,
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
) -> dict[str, int]:
# Lock the file before writing sessions, matching editor initialization and
# ledger permission changes. This also serializes callbacks across generations.
session = await db.scalar(
select(CollaborationSession)
.join(CollaborationFile, CollaborationFile.id == CollaborationSession.file_id)
.where(CollaborationSession.id == session_id)
.with_for_update(of=CollaborationFile)
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
@@ -568,12 +367,8 @@ async def process_callback(
item = await db.get(CollaborationFile, session.file_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
if ledger_access.is_ledger(item):
# Same lock as grant/settings changes: a stale callback cannot race a revocation.
item = await db.scalar(select(CollaborationFile).where(
CollaborationFile.id == item.id,
).with_for_update().execution_options(populate_existing=True))
session.last_callback_at = datetime.now(timezone.utc)
session.active_users = json.dumps(payload.users, ensure_ascii=True)
result = "ACKNOWLEDGED"
saved_revision_id = None
@@ -582,10 +377,6 @@ async def process_callback(
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
if session.generation != item.generation:
result = "STALE"
elif ledger_access.is_ledger(item) and (
item.status != "ACTIVE" or not await _ledger_callback_can_edit(db, item, payload, session)
):
result = "ACCESS_REVOKED"
else:
content = await _download_result(payload.url)
actor = await _callback_user(db, payload, session)
@@ -609,16 +400,10 @@ async def process_callback(
session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc)
result = "UNCHANGED"
elif payload.status == 3:
elif payload.status in {3, 7}:
session.status = "ERROR"
result = "ERROR"
elif payload.status == 7:
# A force-save error does not close the live co-editing session. The
# final status 2 callback can still persist the document normally.
result = "ERROR"
if payload.users or not ledger_access.is_ledger(item):
session.active_users = json.dumps(payload.users, ensure_ascii=True)
db.add(CollaborationCallbackReceipt(
session_id=session.id,
fingerprint=fingerprint,
Binary file not shown.
@@ -1,44 +0,0 @@
"""Abort a deployment when ONLYOFFICE still has live collaborative editors."""
import asyncio
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from fastapi import HTTPException # noqa: E402
from app.core.config import settings # noqa: E402
from app.db.session import SessionLocal # noqa: E402
from app.services.onlyoffice_collaboration_service import list_live_editing_sessions # noqa: E402
async def async_main() -> int:
if not settings.ONLYOFFICE_ENABLED:
print("ONLYOFFICE 未启用,跳过在线编辑会话检查")
return 0
try:
async with SessionLocal() as db:
active = await list_live_editing_sessions(db)
except HTTPException as exc:
print(f"无法确认 ONLYOFFICE 在线编辑状态:{exc.detail}", file=sys.stderr)
return 1
if not active:
print("未检测到 ONLYOFFICE 在线编辑者")
return 0
print("检测到仍在进行的 ONLYOFFICE 在线编辑,会中止本次部署:", file=sys.stderr)
for title, count in active:
print(f"- {title}:{count} 人在线", file=sys.stderr)
print("请通知用户退出编辑器,等待最终保存完成后重新执行部署。", file=sys.stderr)
return 2
def main() -> None:
raise SystemExit(asyncio.run(async_main()))
if __name__ == "__main__":
main()
+1 -189
View File
@@ -4,7 +4,7 @@ import uuid
import zipfile
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, call
from unittest.mock import ANY, AsyncMock
import pytest
from fastapi import HTTPException
@@ -304,18 +304,6 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m
assert onlyoffice_collaboration_service._validate_result_url(
"http://localhost:8888/onlyoffice/cache/result.docx?token=signed"
) == "http://onlyoffice/cache/result.docx?token=signed"
with pytest.raises(HTTPException) as mismatched_origin:
onlyoffice_collaboration_service._validate_result_url(
"https://ctms.example.com/onlyoffice/cache/result.xlsx?token=signed"
)
assert mismatched_origin.value.status_code == 422
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "https://ctms.example.com")
assert onlyoffice_collaboration_service._validate_result_url(
"https://ctms.example.com/onlyoffice/cache/result.xlsx?token=signed"
) == "http://onlyoffice/cache/result.xlsx?token=signed"
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "http://localhost:8888")
for value in (
"http://backend:8000/internal/file",
"http://onlyoffice.evil.example/cache/result.docx",
@@ -328,157 +316,6 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m
onlyoffice_collaboration_service._validate_result_url(value)
@pytest.mark.asyncio
async def test_forgotten_document_command_downloads_and_validates_server_backup(monkeypatch):
key = "ctms-collab-forgotten-key"
recovered = collaboration_service.blank_file_bytes("cell")
request = {}
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"error": 0, "key": key, "url": "http://onlyoffice/cache/forgotten.xlsx"}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, url, *, params, json):
request.update(url=url, params=params, body=json)
return FakeResponse()
monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient())
download = AsyncMock(return_value=recovered)
monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", download)
result = await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell")
assert result == recovered
assert request["url"] == "http://onlyoffice/command"
assert request["params"] == {"shardkey": key}
assert jwt.decode(
request["body"]["token"], settings.ONLYOFFICE_JWT_SECRET, algorithms=["HS256"]
) == {"c": "getForgotten", "key": key}
download.assert_awaited_once_with("http://onlyoffice/cache/forgotten.xlsx")
@pytest.mark.asyncio
async def test_forgotten_document_command_rejects_a_damaged_backup(monkeypatch):
key = "ctms-collab-damaged-key"
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"error": 0, "key": key, "url": "http://onlyoffice/cache/damaged.xlsx"}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, *_args, **_kwargs):
return FakeResponse()
monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient())
monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", AsyncMock(return_value=b"broken"))
with pytest.raises(HTTPException) as error:
await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell")
assert error.value.status_code == 502
@pytest.mark.asyncio
async def test_document_server_info_command_returns_unique_live_users(monkeypatch):
key = "ctms-collab-live-key"
request = {}
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"error": 0, "key": key, "users": ["user-1", "user-1", "user-2"]}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, url, *, params, json):
request.update(url=url, params=params, body=json)
return FakeResponse()
monkeypatch.setattr(
onlyoffice_collaboration_service.httpx,
"AsyncClient",
lambda **_kwargs: FakeClient(),
)
users = await onlyoffice_collaboration_service._document_server_users(key)
assert users == ["user-1", "user-2"]
assert request["url"] == "http://onlyoffice/command"
assert request["params"] == {"shardkey": key}
assert jwt.decode(
request["body"]["token"], settings.ONLYOFFICE_JWT_SECRET, algorithms=["HS256"]
) == {"c": "info", "key": key}
@pytest.mark.asyncio
async def test_document_server_info_command_treats_unknown_key_as_no_live_users(monkeypatch):
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"error": 1}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, *_args, **_kwargs):
return FakeResponse()
monkeypatch.setattr(
onlyoffice_collaboration_service.httpx,
"AsyncClient",
lambda **_kwargs: FakeClient(),
)
assert await onlyoffice_collaboration_service._document_server_users("retired-key") == []
@pytest.mark.asyncio
async def test_live_editing_session_check_filters_stale_active_rows(monkeypatch):
rows = SimpleNamespace(all=lambda: [
("live-key", "正在编辑.xlsx"),
("stale-key", "陈旧记录.xlsx"),
])
db = SimpleNamespace(execute=AsyncMock(return_value=rows))
lookup = AsyncMock(side_effect=[["user-1", "user-2"], []])
monkeypatch.setattr(onlyoffice_collaboration_service, "_document_server_users", lookup)
active = await onlyoffice_collaboration_service.list_live_editing_sessions(db)
assert active == [("正在编辑.xlsx", 2)]
assert lookup.await_args_list == [call("live-key"), call("stale-key")]
@pytest.mark.asyncio
async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path):
user_id = uuid.uuid4()
@@ -1179,31 +1016,6 @@ async def test_callback_error_status_is_recorded_and_acknowledged():
assert db.added[0].result == "ERROR"
@pytest.mark.asyncio
async def test_force_save_error_keeps_the_live_session_active():
session = SimpleNamespace(
id=uuid.uuid4(),
file_id=uuid.uuid4(),
document_key="ctms-collab-force-save-error-key",
generation=1,
status="ACTIVE",
active_users=None,
last_callback_at=None,
)
item = SimpleNamespace(id=session.file_id, generation=1)
db = _CallbackDb(session, item)
result = await onlyoffice_collaboration_service.process_callback(
db,
session.id,
CollaborationCallbackPayload(key=session.document_key, status=7),
)
assert result == {"error": 0}
assert session.status == "ACTIVE"
assert db.added[0].result == "ERROR"
@pytest.mark.asyncio
async def test_force_save_updates_session_recovery_revision(monkeypatch):
session = SimpleNamespace(
-569
View File
@@ -1,569 +0,0 @@
import io
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import DateTime, TypeDecorator
from conftest import _create_test_engine
from app.api.v1.ledgers import router
from app.core.config import settings
from app.core.deps import get_current_user, get_db_session
from app.models.collaboration import CollaborationCallbackReceipt, CollaborationFile, CollaborationRevision, CollaborationSession, CollaborationShareLink
from app.models.user import User, UserStatus
from app.schemas.collaboration import CollaborationCallbackPayload
from app.schemas.ledger import LedgerMemberUpsert, LedgerSettings
from app.services import collaboration_service as collaboration
from app.services import ledger_service as ledgers, onlyoffice_collaboration_service as office, onlyoffice_service
class SQLiteUTCDateTime(TypeDecorator):
"""Preserve the PostgreSQL UTC timestamp contract in the SQLite fixture."""
impl = DateTime(timezone=True)
cache_ok = True
def process_result_value(self, value, dialect):
return value.replace(tzinfo=timezone.utc) if value and value.tzinfo is None else value
@pytest_asyncio.fixture
async def env(monkeypatch, tmp_path):
for column in CollaborationShareLink.__table__.columns:
if isinstance(column.type, DateTime) and column.type.timezone:
monkeypatch.setattr(column, "type", SQLiteUTCDateTime())
engine = await _create_test_engine()
monkeypatch.setattr(collaboration, "COLLABORATION_ROOT", tmp_path)
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "ledger-test-secret-long-enough-for-tests")
monkeypatch.setattr(onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(office, "_document_server_users", AsyncMock(return_value=["live-user"]))
async with AsyncSession(engine, expire_on_commit=False) as db:
users = [User(id=uuid.uuid4(), email=f"ledger-{i}@example.com", password_hash="hash",
full_name=f"Ledger user {i}", clinical_department="test", is_admin=i == 0,
status=UserStatus.ACTIVE) for i in range(4)]
db.add_all(users)
await db.commit()
initial = await ledgers.initialize_ledgers(db, users[0])
app = FastAPI()
app.include_router(router, prefix="/api/v1/ledgers")
identity = SimpleNamespace(user=users[0])
app.dependency_overrides[get_current_user] = lambda: identity.user
app.dependency_overrides[get_db_session] = lambda: db
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
yield SimpleNamespace(db=db, admin=users[0], editor=users[1], viewer=users[2], outsider=users[3],
initial=initial, identity=identity, client=client, app=app)
await engine.dispose()
async def grant(env, user, role, index=0):
item = await ledgers.get_ledger(env.db, env.initial[index].id, env.admin, manage=True, lock=True)
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=user.id, role=role), env.admin)
return item
@pytest.mark.asyncio
async def test_initialization_preserves_both_uploaded_templates_and_is_idempotent(env):
repeated = await ledgers.initialize_ledgers(env.db, env.admin)
assert [r.id for r in repeated] == [r.id for r in env.initial]
for result, (_, title, filename, _) in zip(repeated, ledgers.LEDGER_TEMPLATES):
item = await env.db.get(CollaborationFile, result.id)
assert item.study_id is None and item.folder_id is None and item.scope == "LEDGER"
assert item.title == title
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(ledgers.TEMPLATE_ROOT / filename) as original, zipfile.ZipFile(revision.file_uri) as saved:
assert original.namelist() == saved.namelist()
for part in original.namelist():
if part != "xl/workbook.xml":
assert original.read(part) == saved.read(part)
assert b'lockStructure="1"' in saved.read("xl/workbook.xml")
assert "ledgers" in Path(revision.file_uri).parts
@pytest.mark.asyncio
async def test_failed_ledger_session_recovers_server_backup_under_a_new_document_key(env, monkeypatch):
item = await env.db.get(CollaborationFile, env.initial[0].id)
first = await office.build_editor_config(env.db, item, env.admin)
failed = await env.db.scalar(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
CollaborationSession.generation == item.generation,
))
failed.status = "ERROR"
await env.db.commit()
current = await env.db.get(CollaborationRevision, item.current_revision_id)
recovered_buffer = io.BytesIO(Path(current.file_uri).read_bytes())
with zipfile.ZipFile(recovered_buffer, "a") as package:
package.comment = b"document-server-recovery"
recovery = AsyncMock(return_value=recovered_buffer.getvalue())
monkeypatch.setattr(office, "_recover_forgotten_content", recovery)
reopened = await office.build_editor_config(env.db, item, env.admin)
await env.db.refresh(item)
await env.db.refresh(failed)
sessions = (await env.db.scalars(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
).order_by(CollaborationSession.generation))).all()
recovered_revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert first.config["document"]["key"] != reopened.config["document"]["key"]
assert item.generation == 2
assert failed.status == "RECOVERED"
assert [session.generation for session in sessions] == [1, 2]
assert recovered_revision.source == "SERVER_RECOVERY"
assert recovered_revision.change_summary == "自动恢复在线文档服务器备份"
recovery.assert_awaited_once_with(failed.document_key, "cell")
@pytest.mark.asyncio
async def test_closed_ledger_session_starts_a_new_key_instead_of_reopening_server_cache(env, monkeypatch):
item = await env.db.get(CollaborationFile, env.initial[0].id)
first = await office.build_editor_config(env.db, item, env.admin)
closed = await env.db.scalar(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
CollaborationSession.generation == item.generation,
))
await office.process_callback(env.db, closed.id, CollaborationCallbackPayload(
key=closed.document_key,
status=4,
))
recovery = AsyncMock(return_value=None)
monkeypatch.setattr(office, "_recover_forgotten_content", recovery)
reopened = await office.build_editor_config(env.db, item, env.admin)
await env.db.refresh(item)
await env.db.refresh(closed)
sessions = (await env.db.scalars(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
).order_by(CollaborationSession.generation))).all()
assert first.config["document"]["key"] != reopened.config["document"]["key"]
assert item.generation == 2
assert closed.status == "CLOSED"
assert [session.generation for session in sessions] == [1, 2]
recovery.assert_not_awaited()
@pytest.mark.asyncio
async def test_stale_active_ledger_session_starts_a_new_key_when_document_server_has_no_users(
env, monkeypatch
):
item = await env.db.get(CollaborationFile, env.initial[0].id)
first = await office.build_editor_config(env.db, item, env.admin)
stale = await env.db.scalar(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
CollaborationSession.generation == item.generation,
))
stale.last_callback_at = datetime.now(timezone.utc)
await env.db.commit()
live_users = AsyncMock(return_value=[])
recovery = AsyncMock(return_value=None)
monkeypatch.setattr(office, "_document_server_users", live_users)
monkeypatch.setattr(office, "_recover_forgotten_content", recovery)
reopened = await office.build_editor_config(env.db, item, env.admin)
await env.db.refresh(item)
await env.db.refresh(stale)
assert first.config["document"]["key"] != reopened.config["document"]["key"]
assert item.generation == 2
assert stale.status == "CLOSED"
live_users.assert_awaited_once_with(stale.document_key)
recovery.assert_awaited_once_with(stale.document_key, "cell")
@pytest.mark.asyncio
async def test_new_active_session_is_reused_during_browser_connection_grace(env, monkeypatch):
item = await env.db.get(CollaborationFile, env.initial[0].id)
first = await office.build_editor_config(env.db, item, env.admin)
live_users = AsyncMock(return_value=[])
monkeypatch.setattr(office, "_document_server_users", live_users)
repeated = await office.build_editor_config(env.db, item, env.admin)
assert first.config["document"]["key"] == repeated.config["document"]["key"]
live_users.assert_not_awaited()
@pytest.mark.asyncio
async def test_account_grants_are_independent_and_all_file_routes_require_access(env):
await grant(env, env.editor, "EDITOR")
env.identity.user = env.editor
response = await env.client.get("/api/v1/ledgers")
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
assert [row["id"] for row in response.json()] == [str(env.initial[0].id)]
assert response.json()[0]["can_edit"] is True
other = env.initial[1].id
for suffix in ["", "/editor-config", "/revisions", "/members", "/candidates"]:
assert (await env.client.get(f"/api/v1/ledgers/{other}{suffix}")).status_code == 404
assert (await env.client.post("/api/v1/ledgers/initialize")).status_code == 403
env.identity.user = env.outsider
assert (await env.client.get("/api/v1/ledgers")).json() == []
env.app.dependency_overrides.pop(get_current_user)
assert (await env.client.get("/api/v1/ledgers")).status_code == 401
@pytest.mark.asyncio
async def test_editor_and_viewer_share_session_but_receive_distinct_signed_permissions(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
editor = await office.build_editor_config(env.db, item, env.editor)
viewer = await office.build_editor_config(env.db, item, env.viewer)
assert editor.config["document"]["key"] == viewer.config["document"]["key"]
assert editor.access_mode == "edit" and viewer.access_mode == "view"
assert editor.config["document"]["permissions"]["edit"] is True
assert viewer.config["document"]["permissions"]["edit"] is False
assert not editor.can_save_as and not editor.can_request_edit and not editor.can_download
env.identity.user = env.editor
assert (await env.client.get(f"/api/v1/ledgers/{item.id}/members")).status_code == 403
assert (await env.client.post(f"/api/v1/ledgers/{item.id}/downloads", json={"file_type": "xlsx"})).status_code == 403
with pytest.raises(HTTPException) as error:
await collaboration.restore_revision(env.db, item, item.current_revision_id, env.editor, None)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_neither_editors_nor_admins_can_delete_ledger_or_revision(env):
item = await grant(env, env.editor, "EDITOR")
for user in [env.admin, env.editor]:
env.identity.user = user
assert (await env.client.delete(f"/api/v1/ledgers/{item.id}")).status_code == 405
with pytest.raises(HTTPException) as error:
await collaboration.move_to_trash(env.db, item, user)
assert error.value.status_code == 403
with pytest.raises(HTTPException) as error:
await collaboration.delete_revision(env.db, item, item.current_revision_id, user)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_revocation_invalidates_old_callback_before_result_download(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
config = await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
baseline = item.current_revision_id
download = AsyncMock(return_value=b"should never be downloaded")
monkeypatch.setattr(office, "_download_result", download)
await ledgers.remove_member(env.db, item, env.editor.id, env.admin)
result = await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=config.config["document"]["key"], status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
))
assert result == {"error": 0} and item.current_revision_id == baseline
download.assert_not_awaited()
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
assert receipt.result == "STALE"
@pytest.mark.asyncio
async def test_saved_callback_is_versioned_and_duplicate_is_idempotent(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
config = await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
monkeypatch.setattr(office, "_download_result", AsyncMock(return_value=collaboration.blank_file_bytes("cell")))
payload = CollaborationCallbackPayload(key=config.config["document"]["key"], status=6,
url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)])
await office.process_callback(env.db, session.id, payload)
saved = item.current_revision_id
await office.process_callback(env.db, session.id, payload)
assert item.current_revision_id == saved
assert len(await collaboration.list_revisions(env.db, item)) == 2
@pytest.mark.asyncio
async def test_archive_disables_edit_and_stale_settings_are_rejected(env):
item = await grant(env, env.editor, "EDITOR")
payload = LedgerSettings(title=item.title, description="Archived", status="ARCHIVED", owner_id=item.owner_id,
allow_export=False, allow_sheet_structure_edit=False, generation=item.generation)
await ledgers.update_settings(env.db, item, payload, env.admin)
assert not (await ledgers.read_ledger(env.db, item, env.editor)).can_edit
assert (await office.build_editor_config(env.db, item, env.editor)).access_mode == "view"
with pytest.raises(HTTPException) as error:
await ledgers.update_settings(env.db, item, payload, env.admin)
assert error.value.status_code == 409
@pytest.mark.asyncio
async def test_import_and_restore_keep_previous_versions_and_workbook_protection(env):
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
first = item.current_revision_id
upload = UploadFile(filename="import.xlsx", file=io.BytesIO(collaboration.blank_file_bytes("cell")))
await ledgers.import_content(env.db, item, upload, item.generation, env.admin)
second = item.current_revision_id
await ledgers.restore_revision(env.db, item, first, item.generation, env.admin)
assert item.current_revision_id not in {first, second}
assert len(await collaboration.list_revisions(env.db, item)) == 3
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(revision.file_uri) as package:
assert b'lockStructure="1"' in package.read("xl/workbook.xml")
@pytest.mark.asyncio
async def test_inactive_account_cannot_be_granted_or_save_existing_session(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
env.editor.status = UserStatus.DISABLED
await env.db.commit()
with pytest.raises(HTTPException) as error:
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=env.editor.id, role="MANAGER"), env.admin)
assert error.value.status_code == 422
download = AsyncMock()
monkeypatch.setattr(office, "_download_result", download)
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
))
download.assert_not_awaited()
@pytest.mark.asyncio
async def test_restore_respects_current_unlocked_workbook_policy(env):
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
initial = item.current_revision_id
payload = LedgerSettings(title=item.title, status="ACTIVE", owner_id=item.owner_id,
allow_sheet_structure_edit=True, generation=item.generation)
await ledgers.update_settings(env.db, item, payload, env.admin)
await ledgers.import_content(env.db, item, UploadFile(filename="blank.xlsx", file=io.BytesIO(
collaboration.blank_file_bytes("cell"))), item.generation, env.admin)
await ledgers.restore_revision(env.db, item, initial, item.generation, env.admin)
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(revision.file_uri) as package:
assert b"workbookProtection" not in package.read("xl/workbook.xml")
@pytest.mark.asyncio
async def test_shared_access_uses_global_candidates_and_protects_implicit_managers(env):
item = await grant(env, env.editor, "EDITOR")
base = f"/api/v1/ledgers/{item.id}"
members = (await env.client.get(f"{base}/members")).json()
owner = next(row for row in members if row["user_id"] == str(env.admin.id))
assert owner["protected_label"] == "所有者" and owner["role"] == "MANAGER"
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
candidates = (await env.client.get(f"{base}/candidates")).json()
assert {row["user_id"] for row in candidates} == {str(user.id) for user in [env.admin, env.editor, env.viewer, env.outsider]}
assert all(row["can_be_manager"] and row["can_be_editor"] for row in candidates)
original_title = item.title
result = await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": False})
assert result.status_code == 200 and not result.json()["allow_edit_request"]
assert result.json()["title"] == original_title and not result.json()["allow_export"]
for actor, code in [(env.editor, 403), (env.outsider, 404)]:
env.identity.user = actor
for suffix in ["share-link", "edit-requests", "members", "candidates"]:
assert (await env.client.get(f"{base}/{suffix}")).status_code == code
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})).status_code == code
assert (await env.client.put(f"{base}/share-link", json={"enabled": True})).status_code == code
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == code
@pytest.mark.asyncio
async def test_viewer_edit_request_is_deduplicated_and_approval_grants_editor(env):
item = await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
env.identity.user = env.viewer
assert (await env.client.get(base)).json()["can_request_edit"]
request = await env.client.post(f"{base}/edit-requests")
assert request.status_code == 200
request_id = request.json()["id"]
assert (await env.client.post(f"{base}/edit-requests")).json()["id"] == request_id
read = (await env.client.get(base)).json()
assert read["edit_request_status"] == "PENDING" and not read["can_request_edit"]
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 403
generation = item.generation
env.identity.user = env.admin
assert len((await env.client.get(f"{base}/edit-requests")).json()) == 1
result = await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})
assert result.status_code == 200 and result.json()["status"] == "APPROVED"
env.identity.user = env.viewer
read = (await env.client.get(base)).json()
assert read["role"] == "EDITOR" and read["can_edit"] and read["generation"] == generation + 1
assert not read["can_request_edit"]
@pytest.mark.asyncio
async def test_revoked_requester_cannot_regain_access_through_pending_approval(env):
item = await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
env.identity.user = env.viewer
request_id = (await env.client.post(f"{base}/edit-requests")).json()["id"]
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
env.identity.user = env.admin
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 422
assert not await collaboration.can_edit_file(env.db, item, env.viewer)
@pytest.mark.asyncio
async def test_ownership_transfer_accepts_system_accounts_and_keeps_previous_owner_access(env):
item = await grant(env, env.editor, "MANAGER")
base = f"/api/v1/ledgers/{item.id}"
env.identity.user = env.editor
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 403
env.identity.user = env.admin
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 200
env.identity.user = env.viewer
result = await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.outsider.id)})
assert result.status_code == 200 and result.json()["owner_id"] == str(env.outsider.id)
assert result.json()["can_manage"] and not result.json()["can_transfer_ownership"]
assert item.study_id is None
members = (await env.client.get(f"{base}/members")).json()
admin = next(row for row in members if row["user_id"] == str(env.admin.id))
assert admin["protected_label"] == "系统管理员"
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
async def shared_ledger(env, *, mode="EDIT", password=None):
from app.models.collaboration import CollaborationShareLink
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin)
response = await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={
"enabled": True, "access_mode": mode, "expiry_policy": "SEVEN_DAYS",
"password_mode": "SET" if password else "KEEP", **({"password": password} if password else {}),
})
assert response.status_code == 200
link = await env.db.get(CollaborationShareLink, uuid.UUID(response.json()["id"]))
return item, link, response.json()["share_token"]
@pytest.mark.asyncio
async def test_ledger_share_defaults_closed_and_password_change_revokes_old_credentials(env):
from app.services import collaboration_share_service as shares
base = f"/api/v1/ledgers/{env.initial[0].id}"
initial = (await env.client.get(f"{base}/share-link")).json()
assert not initial["enabled"] and initial["share_token"] is None
item, link, token = await shared_ledger(env, password="test-password")
grant_token = await shares.verify_share_password(env.db, token, "test-password")
shares.validate_access_grant(link, grant_token.access_token)
with pytest.raises(HTTPException) as wrong:
await shares.verify_share_password(env.db, token, "incorrect")
assert wrong.value.status_code == 401
generation = item.generation
await env.client.put(f"{base}/share-link", json={"enabled": True, "password_mode": "SET", "password": "new-password"})
assert item.generation == generation + 1
with pytest.raises(HTTPException):
await shares.resolve_active_share(env.db, token)
with pytest.raises(HTTPException):
shares.validate_access_grant(link, grant_token.access_token)
@pytest.mark.asyncio
async def test_anonymous_ledger_save_keeps_share_identity_and_close_blocks_stale_callback(env, monkeypatch):
item, link, _ = await shared_ledger(env)
config = await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
assert config.access_mode == "edit" and not config.can_save_as and not config.can_download
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
download = AsyncMock(return_value=collaboration.blank_file_bytes("cell"))
monkeypatch.setattr(office, "_download_result", download)
# ONLYOFFICE can send a final save with an empty user list. It must not become an owner save.
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/first.xlsx", users=[],
))
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert revision.source == "SHARE_FORCE_SAVE" and revision.created_by is None
saved = item.current_revision_id
await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={"enabled": False})
download.reset_mock()
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=2, url="http://onlyoffice/after-close.xlsx", users=[],
))
download.assert_not_awaited()
assert item.current_revision_id == saved
with pytest.raises(HTTPException):
await office.build_shared_editor_config(env.db, item, link, client_id="new-client", display_name="访客")
@pytest.mark.asyncio
@pytest.mark.parametrize("mode,expired", [("VIEW", False), ("EDIT", True)])
async def test_anonymous_view_or_expired_link_cannot_save_even_without_callback_users(env, monkeypatch, mode, expired):
from datetime import datetime, timedelta, timezone
item, link, _ = await shared_ledger(env, mode=mode)
await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
if expired:
link.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await env.db.commit()
saved = item.current_revision_id
download = AsyncMock()
monkeypatch.setattr(office, "_download_result", download)
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/denied.xlsx", users=[],
))
download.assert_not_awaited()
assert item.current_revision_id == saved
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
assert receipt.result == "ACCESS_REVOKED"
@pytest.mark.asyncio
async def test_ledger_uses_existing_public_share_http_flow_without_a_project(env):
from app.api.v1.collaboration import public_router
env.app.include_router(public_router, prefix="/api/v1/collaboration/shares")
item, _, token = await shared_ledger(env, password="share-password")
env.app.dependency_overrides.pop(get_current_user)
headers = {"X-CTMS-Share-Token": token}
base = "/api/v1/collaboration/shares"
metadata = await env.client.get(f"{base}/metadata", headers=headers)
assert metadata.status_code == 200 and metadata.json()["requires_password"]
body = {"client_id": "test-client", "display_name": "访客"}
assert (await env.client.post(f"{base}/editor-config", headers=headers, json=body)).status_code == 401
access = await env.client.post(f"{base}/access", headers=headers, json={"password": "share-password"})
assert access.status_code == 200
config = await env.client.post(f"{base}/editor-config", headers=headers,
json={**body, "access_token": access.json()["access_token"]})
assert config.status_code == 200 and config.headers["cache-control"] == "no-store"
assert config.json()["file_name"] == item.title and config.json()["access_mode"] == "edit"
assert not config.json()["can_save_as"] and item.study_id is None
@pytest.mark.asyncio
async def test_revision_names_reuse_collaboration_metadata_without_replacing_content(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
revision_id = item.current_revision_id
generation = item.generation
base = f"/api/v1/ledgers/{item.id}/revisions/{revision_id}"
env.identity.user = env.editor
result = await env.client.patch(base, json={"change_summary": "审核提交版"})
assert result.status_code == 200 and result.json()["change_summary"] == "审核提交版"
assert item.current_revision_id == revision_id and item.generation == generation
assert len(await collaboration.list_revisions(env.db, item)) == 1
for actor, status in [(env.viewer, 403), (env.outsider, 404)]:
env.identity.user = actor
assert (await env.client.patch(base, json={"change_summary": "不应保存"})).status_code == status
env.identity.user = env.editor
other_revision = env.initial[1].current_revision_id
assert (await env.client.patch(f"/api/v1/ledgers/{item.id}/revisions/{other_revision}", json={"change_summary": "跨台账"})).status_code == 404
env.identity.user = env.admin
await env.client.patch(f"/api/v1/ledgers/{item.id}", json={
"title": item.title, "status": "ARCHIVED", "owner_id": str(item.owner_id), "generation": item.generation,
})
assert (await env.client.patch(base, json={"change_summary": "归档后"})).status_code == 403
@pytest.mark.asyncio
async def test_history_export_uses_live_export_permission_and_checks_revision_belongs_to_ledger(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
path = f"{base}/revisions/{item.current_revision_id}/download"
for actor in [env.editor, env.viewer]:
env.identity.user = actor
assert (await env.client.get(path)).status_code == 403
env.identity.user = env.admin
response = await env.client.get(path)
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert response.content == Path(revision.file_uri).read_bytes()
await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})
env.identity.user = env.viewer
assert (await env.client.get(path)).status_code == 200
assert (await env.client.get(f"{base}/revisions/{env.initial[1].current_revision_id}/download")).status_code == 404
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
assert (await env.client.get(path)).status_code == 404
env.identity.user = env.outsider
assert (await env.client.get(path)).status_code == 404
-4
View File
@@ -119,10 +119,6 @@ services:
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
ONLYOFFICE_ENABLED: ${ONLYOFFICE_ENABLED:-true}
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?请先运行安装脚本生成 ONLYOFFICE_JWT_SECRET}
ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice}
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:?请先运行安装脚本生成 ONLYOFFICE_INSTANCE_ID}
depends_on:
db:
condition: service_healthy
@@ -28,14 +28,14 @@ npm run desktop:build:app
- [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。
- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。
- [ ] 在正式 release tag 环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、当前精确版本的平台签名策略、updater 私钥和生产 artifact HTTPS 基址齐备。
- [ ] macOS app 已完成 Apple 签名/公证;若当前精确版本获批平台签名例外,则已验证 `Signature=adhoc`、确认未公证并附带 Gatekeeper 风险说明。
- [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 返回 `Valid`;若当前精确版本获批例外,则应用和安装器必须返回 `NotSigned` 并附带 SmartScreen 风险说明。
- [ ] 在正式 release tag 和签名环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址齐备。
- [ ] macOS app 已签名和公证。
- [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 对应用和安装器均返回 `Valid`。
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。
- [ ] 始终设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 `REQUIRE_UPDATER_SIGNING=true`;默认 macOS 路径再设置 Apple 变量和 `REQUIRE_DESKTOP_SIGNING=true`,例外路径则设置 `DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception`、`ALLOW_UNSIGNED_PLATFORM_RELEASE=true` 并执行 `npm run desktop:build:macos-unsigned-release -- --ci`。
- [ ] 默认 Windows 路径设置 PFX、密码、`WINDOWS_TIMESTAMP_URL` 和 `REQUIRE_WINDOWS_SIGNING=true`;例外路径不得伪装签名状态,构建后必须对应用和安装器验证 `NotSigned`。
- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build:macos-release -- --ci`。
- [ ] 设置 Windows PFX、密码、`WINDOWS_TIMESTAMP_URL` 和 updater 私钥后,以 `REQUIRE_WINDOWS_SIGNING=true` 执行 readiness,再执行 `npm run desktop:build:windows-release -- --ci`。
- [ ] 正式 updater feed 使用 `--artifact <CTMS.app.tar.gz>` 和 `--platform-artifact windows-x86_64=<CTMS.nsis.zip>` 汇总生成同一个 `latest.json` 与 `SHA256SUMS.txt`。
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`,并确认两个平台的 updater artifact、`.sig`、安装包、provenance、checksum manifest 和 `latest.json` 均通过校验。
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64`,并确认两个平台的 updater artifact、`.sig`、安装包、checksum manifest 和 `latest.json` 均通过校验。
- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
@@ -70,7 +70,7 @@ npm run desktop:build:app
- [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。
- [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。
- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。
- [ ] Desktop release candidate workflow 只允许从 `vX.Y.Z` tag 运行;先按精确版本解析平台签名策略,macOS 和 Windows 原生 job 分别验证实际签名状态,再由聚合 job 生成包含 `darwin-aarch64`、`darwin-x86_64` 和 `windows-x86_64` 的 feed、provenance、checksum 清单和 verified release directory。
- [ ] signed Desktop release candidate workflow 只允许从 `vX.Y.Z` tag 运行;macOS 和 Windows 原生 job 分别完成签名验证,再由聚合 job 生成包含 `darwin-aarch64`、`darwin-x86_64` 和 `windows-x86_64` 的 feed、checksum 清单和 verified release directory。
人工复审还必须确认:
@@ -88,8 +88,7 @@ npm run desktop:build:app
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
| 构建默认服务器地址已注入 | 不适用 | 必测 | 首次启动使用 `VITE_DESKTOP_SERVER_URL`,不要求重复输入;运行时代码不含生产域名 |
| 构建默认地址和本地配置均缺失 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
| 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 |
@@ -277,22 +276,6 @@ npm run desktop:build:app
创建正式 tag 前仍必须由发布负责人确认:
- GitHub Actions 已配置 updater secrets 和 versioned artifact base URL;默认平台签名路径还必须配置 Apple 与 Windows secrets 和 Windows timestamp URL。
- 若使用默认 Windows 签名路径,组织证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。
- GitHub Actions 已配置 updater、Apple 与 Windows 签名 secrets,以及 versioned artifact base URL 和 Windows timestamp URL。
- 组织 Windows 证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。
- macOS/Windows 原生 candidate job、联合 feed 校验、真实安装/升级和生产下载源上传全部通过。
## 13. 2026-07-17 v0.1.0 平台签名例外记录
经发布负责人明确批准,v0.1.0 可在无法提供 Apple Developer 和 Windows 组织代码签名凭据时受控分发。该批准仅覆盖精确版本 `0.1.0`:
- `frontend/desktop-release-policy.json` 默认仍为 `signed`,仅将 v0.1.0 列入 `unsignedPlatformExceptions`;后续版本不会继承本例外。
- macOS 使用 Tauri `signingIdentity: "-"` 生成 ad-hoc 签名 Universal app/DMG,必须通过 `codesign --verify` 并确认 `Signature=adhoc`,不执行或声称 Apple 公证。
- Windows 应用和 NSIS 安装器不做 Authenticode 签名,必须通过 `Get-AuthenticodeSignature` 确认 `NotSigned`,不执行或声称 RFC 3161 时间戳。
- 两端仍必须从同一 `v0.1.0` tag 和 SHA 构建,使用同一 updater 私钥生成 `.sig`,并通过联合 feed、checksum 和 provenance 校验。
- GitHub Actions 额度不可用期间,发布负责人额外批准从最终 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS 制品;Windows 必须在恢复后从同一 tag/SHA 后补。
- macOS 先行的用户可见 GitHub Release 只包含 DMG、仅覆盖该 DMG 的 checksum 和 GitHub 自动源码归档;Release Notes 必须明确 macOS ad-hoc/未公证风险和 Windows pending,且不得包含或替换生产 `latest.json`。updater 包及 `.sig`、完整 checksum、provenance、`UNSIGNED-PLATFORM` 和 Windows pending 证据必须先复制到权限受限且被 Git 忽略的私有发布目录。
- Windows `NotSigned` 实物和联合 feed 全部验证通过后,才允许激活生产 updater feed;tag 不得为补充 Windows 制品而移动。
- 制品与 Actions artifact 名称必须含 `_UNSIGNED` 或 `UNSIGNED-PLATFORM`,私有证据/完整 updater 发布目录必须携带 `UNSIGNED-PLATFORM-RELEASE.txt` 和 `DESKTOP-RELEASE-PROVENANCE.json`;installer-only GitHub Release 通过 `_UNSIGNED` 安装包名和 Release Notes 呈现风险,不要求公开展示内部证据文件。
- 受控分发说明必须明确提示 macOS Gatekeeper 与 Windows SmartScreen 警告;平台未签名制品不得描述为 Apple/Microsoft 信任或已公证/已 Authenticode 签名。
本地实物验证已完成:使用当前 updater 私钥成功构建 Universal `x86_64 arm64` macOS app、DMG、`.app.tar.gz` 和 `.sig`;`codesign --verify --deep --strict` 通过,签名详情为 `Signature=adhoc`、`TeamIdentifier=not set`,Gatekeeper 拒绝符合预期。Windows `NotSigned`、NSIS 和 updater `.sig` 仍须由 `windows-latest` 原生 job 在正式 tag 上验证。
-24
View File
@@ -305,33 +305,9 @@ Before promoting `main` to `release`, confirm:
- release checklist is complete
- production initialization, build, and smoke verification are complete
- Desktop updater signatures are present and verified
- platform signing is either complete or the exact version has an approved,
checked-in exception in `frontend/desktop-release-policy.json`; the current
v0.1.0 exception requires macOS ad-hoc signing, unsigned Windows artifacts,
controlled distribution, and explicit trust warnings
- rollback or remediation path is clear
- release tag has been prepared
Platform signing remains the default for versions without an exact approved
exception. An exception never permits disabling Tauri updater signatures or
building Web, macOS, and Windows from different tags or commits.
For v0.1.0 only, the release owner also approved a staged contingency while
hosted Actions capacity is unavailable: macOS may be built on a controlled
local macOS host from the immutable final `v0.1.0` tag and published first.
Windows remains pending and must later be built from that same tag and SHA.
The user-facing macOS-only GitHub Release may contain only the DMG and a
checksum manifest that covers that installer; platform-trust and
Windows-pending status must remain explicit in the Release Notes. The updater
package and signature, full checksum manifest, provenance, warning, and
Windows-pending evidence must be retained in a Git-ignored private release
directory. After Windows is built from the same tag and SHA, those updater
artifacts may be published only to a separately configured anonymous HTTPS
update origin. The staged release must not publish or replace production
`latest.json`; updater feed activation waits for combined macOS and Windows
verification.
Reference:
- [docs/guides/release-checklist.md](guides/release-checklist.md)
-48
View File
@@ -1,48 +0,0 @@
# 全局协作台账(第一版)
台账与项目平级,从网页端、桌面端工作台进入 `/ledgers/:fileId`,不依赖项目 ID、项目成员或项目权限。进入台账时清理当前项目上下文。
## 初始模板
系统管理员在工作台点击“初始化两本台账”,幂等创建:
- 医学事务部合同台账明细表:华邦云合同编号、医学事务部合同编号、项目代码、合同内容、备注。
- 医学事务部临床运营项目编号:项目编号、产品名称、产品代码、临床试验内容、生效日期。
模板来自用户提供的同名 XLSX,保存在 `backend/app/templates/ledgers/`;原文件只有表头和空白格式行,无业务记录。初始工作表、列宽、单元格样式保持原样,仅应用现有工作簿结构保护。管理员可修改名称、说明或导入新的 XLSX;导入产生新修订并保留原版本,不改变台账 ID 和授权。
## 权限
每本台账独立授予系统内有效账号 `VIEWER`、`EDITOR`、`MANAGER`。所有者及系统管理员具有管理权限,其隐含权限显示在授权列表中且不能移除;所有者或系统管理员可将所有权转让给有效系统账号,原所有者保留管理权限。未授权账号看不到台账,直接访问返回 404。无项目成员身份的有效账号也可获得台账授权。
查看者只读,编辑者可编辑内容,管理者可修改设置、维护授权、归档、导入和恢复历史版本。文件与历史版本不提供删除 API,协作底层删除函数同时拒绝台账删除。归档后所有账号均以只读模式打开。项目协作编辑者原有的历史恢复权限不扩展到台账。
“不可删除”指整个台账文件和历史版本。单元格、行和记录允许编辑及清空;工作簿结构保护控制工作表增删,不提供记录级防删除或单元格审计。文件历史版本不等同于逐项业务审计。
查看者/编辑者及匿名访问者的下载、打印和复制由台账导出开关控制;管理者始终可导出。台账不支持另存为项目文件。
台账卡片的“台账管理”提供三个并列入口,各自打开独立弹窗,不通过 Tab 或弹窗内链接跳转:
- 信息维护:`LedgerInfoDialog.vue` 维护名称、用途说明和使用/归档状态,展示所有者。
- 访问与权限:与项目共同使用 `CollaborationAccessDialog.vue`,通过 `collaborationAccess.ts` 适配各自 API;包含账号授权、协作者选择、匿名访问、权限设置、编辑申请审批及所有权转让。项目选择器继续使用项目成员;台账选择器使用系统有效账号,不需要项目上下文。
- 历史版本:`LedgerHistoryDialog.vue` 作为台账入口,直接复用项目的 `CollaborationHistoryDialog.vue`。按日期分组显示时间、更新者、大小、来源、版本名称和当前版本,支持仅看已命名版本、来源筛选、手动刷新和延迟保存回调刷新。编辑者及管理者可命名版本,导入及恢复限使用中台账的管理者;台账历史始终不提供删除操作。预览复用完整的 Office 只读预览页,通过不含项目 ID 的台账路由打开;另存为按当前导出权限保存本地 XLSX,项目历史另存为仍创建项目工作区文件。
所有弹窗挂载到页面根节点。信息维护只加载台账元数据,历史版本单独加载版本列表;关闭后重新打开会刷新数据。
匿名访问默认关闭。管理员可以使用与项目相同的只读/编辑模式、有效期、密码和复制链接功能,复用现有公开分享入口、密码校验和签名凭证。台账分享配置变化会更新链接版本和文件代次,之前复制的台账链接及密码凭证随之失效,需复制新链接。已授权查看者可在开放申请时提交编辑申请;管理者在访问与权限中审批,台账申请不会写入项目通知。
## 协作与保存边界
复用 `CollaborationWorkspace.vue`、ONLYOFFICE 配置、会话、签名回调和不可变内容修订。`collaboration_files.scope` 显式区分 `PROJECT` 与 `LEDGER`;数据库约束保证台账的 `study_id`/`folder_id` 为空且只允许 XLSX。台账内容保存在上传目录的 `collaboration/ledgers/<file-id>/`,不会进入项目目录。
设置/授权变化、编辑申请获批、所有权转让、匿名访问变更、导入、恢复推进文件代次;旧代次保存回调不覆盖新内容。台账回调在文件锁内复核代次、归档状态与保存账号有效编辑权限;匿名回调另行检查链接仍启用、未过期且允许编辑,不回退为所有者身份。客户端每 15 秒及窗口重新获得焦点时复核账号访问状态,权限/代次变化或验证失败时卸载编辑器并提示重新打开。台账 API 禁用缓存和自动网络重试,不使用离线存储。
信息维护、导入及历史恢复在更新前提示协作者先保存;访问与权限沿用项目界面的即时保存交互。变更代次会使原会话未保存内容无法继续写入,权限变更前应先保存内容。授权撤销立即阻断旧代次保存,已显示的账号编辑页面会在下一次客户端检查时关闭。历史预览沿用已有短时签名内部只读内容链路。
## 迁移与验证
迁移 `20260903_01` 位于 `20260716_05` 之后;已有文件默认为 `PROJECT`。首次创建台账后,降级迁移主动拒绝删除台账数据,应保留数据并前向修复。
验证覆盖模板逐部件保真、初始化幂等、独立账号授权、未授权 API、查看/编辑配置、文件和历史禁止删除、撤权/停用后的保存拒绝、回调幂等、归档、导入与恢复,以及历史命名和导出的角色限制、跨台账隔离。前端覆盖无项目入口、初始化、加载失败重试、全局编辑器及权限变化卸载,以及共享历史的筛选、命名、恢复、另存为与预览。
本次未新增 Tauri command、capability、CSP 或底层存储能力,文件导入与下载使用现有运行时入口;现有桌面发布检查脚本适用。
+5 -11
View File
@@ -137,9 +137,7 @@ bash scripts/install.sh release --base-url https://ctms.example.com
可选参数:
```text
--base-url <url> 对外访问基址,同时用于健康检查及 ONLYOFFICE 保存地址校验。dev/main 默认
http://127.0.0.1:8888;已有环境优先复用 .env 的 CTMS_BASE_URL,
首次 release 安装必填或交互输入。只接受协议与主机组成的 origin。
--base-url <url> 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888,release 必填或交互输入。
--yes 跳过交互确认,适合自动化执行。
--skip-build 跳过镜像构建,仍会执行 docker compose up -d。
--skip-migrate 跳过 alembic upgrade head。
@@ -149,14 +147,12 @@ bash scripts/install.sh release --base-url https://ctms.example.com
```text
1. 检查 docker、docker compose、openssl、curl。
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建;将
`CTMS_BASE_URL` 与 `FRONTEND_PUBLIC_URL` 同步为 `--base-url`,确保公开缓存 URL 可通过保存回调校验。
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建。
3. 所有环境自动生成独立的 ONLYOFFICE JWT 与稳定实例标识;新建 main/release 的 .env 时同时生成登录 JWT 和 RSA 私钥。
4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。
5. 在重启服务前通过 ONLYOFFICE `info` 命令检查真实在线编辑者;存在在线编辑时中止部署,待用户退出并完成最终保存后重试。
6. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
7. 执行 docker compose run --rm backend python -m alembic upgrade head。
8. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
5. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
6. 执行 docker compose run --rm backend python -m alembic upgrade head。
7. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
```
更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。
@@ -197,8 +193,6 @@ cat > .env <<'EOF'
COMPOSE_PROJECT_NAME=ctms_dev
ENV=development
JWT_SECRET_KEY=dev-secret
CTMS_BASE_URL=http://127.0.0.1:8888
FRONTEND_PUBLIC_URL=http://127.0.0.1:8888
LOGIN_RSA_KEY_ID=default
LOGIN_RSA_PRIVATE_KEY=
ONLYOFFICE_ENABLED=true
+3 -8
View File
@@ -106,9 +106,7 @@ npm run desktop:build:app
npm run desktop:build:app
```
正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥,updater 签名不可关闭。平台签名默认路径仍要求 macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行,Windows 设置 PFX、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行。只有 `frontend/desktop-release-policy.json` 对当前精确版本存在已批准例外时,workflow 才可改为 macOS ad-hoc、Windows Authenticode 未签名路径;例外制品必须标注 `UNSIGNED-PLATFORM`、生成 provenance 和风险说明。两个平台与联合 feed 校验通过后才能汇总正式 updater feed。
v0.1.0 额外批准 GitHub Actions 额度不可用时的分阶段应急路径:先在受控 macOS 主机从最终、不可移动的 `v0.1.0` tag/SHA 本地构建 macOS ad-hoc 制品,面向安装用户的 GitHub Release 只保留 DMG 与仅覆盖该安装包的 checksum,平台未签名警告和 Windows pending 状态写入 Release Notes。macOS updater 包及 `.sig`、完整 checksum、provenance、风险说明和 Windows pending 证据必须保存在被 Git 忽略的私有发布目录;Windows 恢复后必须从同一 tag/SHA 后补,并在联合验证通过后把 updater 制品发布到可匿名读取的独立 HTTPS 更新源。macOS 先行阶段不得发布或替换生产 `latest.json`,联合 feed 必须等待 Windows `NotSigned` 实物验证完成。
正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥;macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行 readiness 与 Universal 构建,Windows 设置 PFX 证书、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行 readiness、签名 NSIS 构建和 Authenticode 校验。两个平台通过后才能汇总正式 updater feed。
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
@@ -208,7 +206,7 @@ dev -> main
- 前后端测试通过
- 网页端构建通过
- macOS 桌面端构建通过
- Windows x64 NSIS 兼容性构建通过;正式发布时平台签名和时间戳验证通过,或当前精确版本的已批准平台签名例外验证通过
- Windows x64 NSIS 兼容性构建通过;正式发布时签名和时间戳验证通过
- 数据库迁移经过验证
- 已知风险和回滚方式已记录
@@ -297,9 +295,7 @@ git tag -a v1.2.0 -m "CTMS v1.2.0"
git push origin v1.2.0
```
网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。两个平台的 updater 签名制品必须始终验证通过;macOS 签名/公证和 Windows 代码签名/RFC 3161 时间戳必须验证通过,除非 `frontend/desktop-release-policy.json` 对该精确版本记录了已批准例外。采用例外时必须验证 macOS 确为 ad-hoc、Windows 确为 `NotSigned`,并在 Release Notes、私有发布证据、完整 updater checksum 和 provenance 中记录 `UNSIGNED-PLATFORM` 风险;面向安装用户的 Release 可按精确版本策略只显示安装包与安装包 checksum,但 updater 制品及验证证据必须保留,之后才能最后原子替换生产 `latest.json`。
若执行 v0.1.0 的已批准分阶段应急路径,首次 macOS Release 可先于 Windows 发布,但 tag/SHA 不得移动;GitHub Release 只保留 DMG、仅覆盖该 DMG 的 checksum 和 GitHub 自动提供的源码归档,并在 Release Notes 中明确标记未签名风险与 Windows pending。updater 包、`.sig`、完整 checksum、provenance 和说明文件必须在私有发布目录留存。Windows 后补且联合 feed 验证通过后,才允许把这些 updater 制品发布到独立匿名 HTTPS 更新源并按上述顺序激活生产 updater feed。
网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。macOS 签名/公证、Windows 代码签名/RFC 3161 时间戳以及两个平台的 updater 签名制品必须全部验证通过后,才能分发安装包并最后原子替换生产 `latest.json`。
发布记录至少包含:
@@ -454,7 +450,6 @@ git log --oneline --decorate --graph --all -30
- [ ] `npm run build` 通过
- [ ] `npm run desktop:build:app` 通过
- [ ] 正式桌面发布构建已使用 updater 签名私钥执行
- [ ] 平台签名已验证,或当前精确版本已在 `frontend/desktop-release-policy.json` 获批例外且 Release Notes、私有 `UNSIGNED-PLATFORM` 证据、provenance 和完整 updater checksum 齐备;若 GitHub Release 采用 installer-only profile,公开 checksum 只覆盖公开安装包
- [ ] 数据库迁移与回滚方案确认
- [ ] 发布说明完成
- [ ] `main -> release` 合并完成
+24 -93
View File
@@ -87,11 +87,7 @@ npm run desktop:build:app
`release:env:check` verifies build channel and commit metadata, and becomes
platform-strict with `REQUIRE_DESKTOP_SIGNING=true` on macOS or
`REQUIRE_WINDOWS_SIGNING=true` on Windows. Formal native jobs also set
`REQUIRE_UPDATER_SIGNING=true`, `DESKTOP_RELEASE_PLATFORM`, and the
version-derived `DESKTOP_PLATFORM_SIGNING_MODE`. The exact-version policy in
`frontend/desktop-release-policy.json` is checked with
`npm run desktop:release-policy:check`.
`REQUIRE_WINDOWS_SIGNING=true` on Windows.
`desktop:release:check` statically verifies the Tauri bundle, updater public
key, CSP, capability scopes, command allowlist, query-token ban, generic system
notification boundary, CI gate coverage, and secure session token boundary. The
@@ -136,10 +132,10 @@ The release pipeline must:
1. build from the accepted release tag and commit;
2. build the Universal macOS app/DMG and Windows x64 NSIS installer from that tag;
3. use Apple signing/notarization and Windows Authenticode/RFC 3161 signing by default, or use a checked-in exact-version exception that constrains macOS to ad-hoc signing and Windows to Authenticode-unsigned output;
3. sign and notarize macOS, and Authenticode-sign Windows with an RFC 3161 timestamp;
4. produce macOS `.app.tar.gz` and Windows `.nsis.zip` updater artifacts plus their `.sig` files with the shared updater private key;
5. generate `DESKTOP-RELEASE-PROVENANCE.json`, one combined `latest.json`, and a checksum manifest;
6. verify all macOS and Windows entries with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`;
5. generate one combined `latest.json` and checksum manifest with `npm run desktop:update-feed:create`;
6. verify all macOS and Windows entries with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64`;
7. upload immutable artifacts first;
8. atomically replace `latest.json` last.
@@ -147,61 +143,22 @@ For Universal macOS artifacts, `latest.json` must provide both
`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
update package.
The same feed must also contain `windows-x86_64`, pointing to the updater-signed
NSIS `.nsis.zip` package. The Windows `.exe` installer is distributed next to
the updater package but is not used as the updater URL. Tauri updater signing is
mandatory in both platform-signed and platform-signing-exception modes.
The same feed must also contain `windows-x86_64`, pointing to the signed NSIS
`.nsis.zip` updater package. The Windows `.exe` installer is distributed next
to the updater package but is not used as the updater URL.
The formal release candidate workflow lives at
The signed release candidate workflow lives at
`.github/workflows/desktop-release-candidate.yml`. It must be run from a
matching `vX.Y.Z` tag and produces a verified release directory as a GitHub
artifact. That artifact is still only a release candidate; the release owner
must upload immutable files to the production download origin and replace
`latest.json` atomically after validation.
Platform signing defaults to `signed`. An exception is allowed only when
`frontend/desktop-release-policy.json` names the exact product version and
records release-owner approval. The current v0.1.0 exception uses macOS ad-hoc
signing and unsigned Windows application/installer binaries. Its artifact names
and verified updater release directory contain `UNSIGNED-PLATFORM`; the private
evidence/update directory must include `UNSIGNED-PLATFORM-RELEASE.txt`,
`DESKTOP-RELEASE-PROVENANCE.json`, and the full `SHA256SUMS.txt`. The
user-facing GitHub Release may use the exact-version
`installer-and-checksum-only` profile, with platform warnings in Release Notes
and a separate checksum manifest covering only the displayed installer. This
does not establish Apple or Microsoft publisher trust, and Gatekeeper or
SmartScreen warnings are expected. Later versions return to the signed default
unless separately approved.
### v0.1.0 staged macOS contingency
The release owner approved one additional v0.1.0 contingency for unavailable
hosted Actions capacity. A controlled local macOS host may build the macOS
ad-hoc artifacts from the immutable final `v0.1.0` tag and publish them first.
That initial user-facing GitHub Release contains only the DMG and a
`SHA256SUMS.txt` that covers the DMG, in addition to GitHub's automatic source
archives. The Release Notes must state that macOS is ad-hoc/not notarized and
that Windows remains pending. The macOS updater package and `.sig`, full
checksum manifest, provenance, unsigned-platform warning, and Windows-pending
notice must be copied to a permission-restricted, Git-ignored private release
directory before any visible asset is removed. Windows must later be built from
the same tag and SHA.
The staged macOS release is an installer distribution, not an activated
cross-platform updater release. Do not upload or replace production
`latest.json` until the Windows `NotSigned` installer/updater has been built and
the combined macOS/Windows feed passes full verification. The combined updater
artifacts, signatures, provenance, full checksum manifest, and `latest.json`
must use a separately configured HTTPS update origin that anonymous production
clients can read; a private GitHub Release is not a valid production updater
origin. This fallback is limited to v0.1.0 and does not authorize local formal
builds for later versions.
## Windows Release and Internal Validation
Windows x64 NSIS is an approved formal Desktop target. Formal Windows builds
run in `.github/workflows/desktop-release-candidate.yml` from the same exact
`vX.Y.Z` tag and SHA as Web and macOS. The default signed path must:
`vX.Y.Z` tag and SHA as Web and macOS. They must:
- import a Base64-encoded PFX from `WINDOWS_CERTIFICATE` using
`WINDOWS_CERTIFICATE_PASSWORD`;
@@ -213,12 +170,6 @@ run in `.github/workflows/desktop-release-candidate.yml` from the same exact
- publish the signed NSIS `.exe`, `.nsis.zip`, and `.nsis.zip.sig` into the
combined verified Desktop release directory.
For an approved exact-version unsigned-platform exception, the Windows job
must instead leave the application and installer Authenticode-unsigned, require
`Get-AuthenticodeSignature` to return `NotSigned`, append `_UNSIGNED` to the
installer and updater artifact names, and still create and verify the updater
`.sig`. A certificate secret and timestamp URL are not required in this mode.
Windows release validation also covers:
- WebView2 runtime prerequisite behavior;
@@ -235,33 +186,19 @@ only, runs on `windows-latest`, injects `VITE_BUILD_CHANNEL` and
and Desktop safety gates, builds an unsigned NSIS installer with updater
artifacts disabled, and uploads the `.exe` plus `SHA256SUMS.txt` as a GitHub
Actions artifact. This workflow is for internal compatibility verification
only; it must not generate `latest.json`, update feeds, verified release
only; it must not generate `latest.json`, update feeds, signed release
directories, or formal Windows release artifacts. A successful internal build
does not substitute for the tag-only formal workflow, including when that
formal workflow uses an approved platform-signing exception.
does not substitute for the signed tag-only release workflow.
The formal workflow always requires these organization settings:
The formal workflow requires these organization settings:
- secrets: `TAURI_SIGNING_PRIVATE_KEY` and
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`;
- client default CTMS origin: repository variable `VITE_DESKTOP_SERVER_URL`;
- secrets: `TAURI_SIGNING_PRIVATE_KEY`,
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, `WINDOWS_CERTIFICATE`, and
`WINDOWS_CERTIFICATE_PASSWORD`;
- variable: `WINDOWS_TIMESTAMP_URL`;
- shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the
manual workflow input.
`VITE_DESKTOP_SERVER_URL` must be an HTTPS origin without credentials, path,
query, or fragment. Vite embeds it as the Desktop client's first-run default;
the runtime source must not contain the production hostname. A user can still
override this default in Desktop server settings, where the persisted manual
value takes precedence and switching origins clears the existing session and
server-scoped client state. Do not reuse this value as
`DESKTOP_UPDATE_BASE_URL`: the latter points to immutable updater artifacts,
not the CTMS business API.
The default signed path additionally requires `WINDOWS_CERTIFICATE`,
`WINDOWS_CERTIFICATE_PASSWORD`, and `WINDOWS_TIMESTAMP_URL`, plus the Apple
credentials documented by the release owner. The v0.1.0 exception does not
require those platform certificate settings.
The checked-in workflow implements the exportable PFX path. Confirm that the
organization's certificate policy permits an exportable CI certificate before
provisioning it. If the selected CA provides only hardware- or cloud-backed
@@ -277,7 +214,6 @@ npm ci
npm run version:check
export VITE_BUILD_CHANNEL=release
export VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
export VITE_DESKTOP_SERVER_URL="https://ctms.example.com"
npm run release:env:check
npm run runtime:check
npm run desktop:release:check
@@ -288,21 +224,16 @@ npm run build
npm run desktop:build:app
export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
export REQUIRE_UPDATER_SIGNING=true
export DESKTOP_RELEASE_PLATFORM=macos
export DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception
export ALLOW_UNSIGNED_PLATFORM_RELEASE=true
export REQUIRE_DESKTOP_SIGNING=true
npm run release:env:check
npm run desktop:release-readiness:check
npm run desktop:build:macos-unsigned-release -- --ci
npm run desktop:build:macos-release -- --ci
npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --platform-artifact windows-x86_64=<CTMS.nsis.zip> --include <CTMS.dmg> --include <CTMS-installer.exe> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64
```
The macOS and Windows builds run in their native CI jobs and always use the same
updater signing key. In the default path, macOS requires Apple
signing/notarization credentials and Windows requires the PFX/password and
timestamp URL. In the approved v0.1.0 exception, macOS must verify
`Signature=adhoc`, Windows must verify `NotSigned`, and both must carry explicit
platform-trust warnings. Native artifacts are aggregated only after both jobs
and the combined updater feed pass.
The macOS and Windows signed builds run in their native CI jobs. macOS requires
the Apple signing/notarization credentials defined by the release owner;
Windows requires the PFX certificate/password and timestamp URL above. Both use
the same updater signing key and are aggregated only after both native jobs
pass. Unsigned internal builds are not formal distributions.
+1 -9
View File
@@ -28,15 +28,7 @@ bash scripts/onlyoffice-dev-up.sh --rotate-secret
轮换会强制重建相关容器,已签发但尚未使用的短时预览配置会立即失效。生产环境不使用该自动生成流程,仍必须由部署密钥管理系统显式提供密钥。
可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像只额外增加 Noto Sans CJK/Noto Serif CJK;其他字体仅继承自获准使用的上游镜像。
## 字体许可边界
项目只在派生镜像中额外安装可再分发的 Noto CJK 字体,不打包、复制、下载或自动注入部署方提供的宋体、Times New Roman 等专有字体。文档指定了未安装字体时,ONLYOFFICE 会使用可用字体替代,版式可能产生差异。
部署方不得将来源不明或没有服务器部署及再分发许可的字体放入项目目录、Docker 构建上下文或 `onlyoffice_data` 卷。确需增加其他字体时,应先取得相应授权并独立完成合规评估;当前安装和更新脚本不提供专有字体注入入口。
`ONLYOFFICE_IMAGE` 上游基础镜像可能自带其他字体;其内容和许可不属于项目字体注入流程。分发派生镜像前仍应单独审查获准使用的上游镜像,不能以本项目已移除自定义字体作为上游字体合规结论。
可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像只增加 Noto Sans CJK/Noto Serif CJK,不包含微软字体。
## 配置边界
-5
View File
@@ -45,12 +45,8 @@
5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。
6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。
状态 2、3 或 4 的最终回调结束当前编辑生命周期,已关闭会话不得重新激活或复用原 `document.key`。数据库仍标记为 `ACTIVE`、但已接收过回调且 Document Server 的 `info` 命令确认没有在线编辑者时,同样按中断会话退役;刚签发但尚未收到首次连接回调的 key 保留 15 秒连接宽限,避免并发配置请求误判。对于保存错误或这类中断会话,再次打开文件时后端通过 ONLYOFFICE `getForgotten` 命令检查服务器是否保留了备份副本;存在备份时,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。正常的无修改关闭只推进代次,不做不必要的备份查询。如果文档服务器没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。
内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。
安装和更新流程在重启服务前使用 ONLYOFFICE `info` 命令核实数据库中 `ACTIVE` 会话的真实在线用户。仍有编辑者时部署会在服务重启前终止,并只显示文件名和在线人数;应通知用户退出编辑器并等待最终保存后重试。数据库中的陈旧 `ACTIVE` 记录若已不在 Document Server 中存在,不会误阻塞部署。不要通过强制重启或删除 ONLYOFFICE 数据卷绕过检查。
## 本地开发
标准开发安装会直接启动 ONLYOFFICE:
@@ -64,7 +60,6 @@ bash scripts/install.sh dev
关键配置:
- `ONLYOFFICE_ENABLED`
- `FRONTEND_PUBLIC_URL`(必须与用户访问 CTMS 的 origin 一致;安装脚本会同步为 `--base-url`)
- `ONLYOFFICE_JWT_SECRET`
- `ONLYOFFICE_INTERNAL_URL`
- `ONLYOFFICE_STORAGE_BASE_URL`
-2
View File
@@ -2,8 +2,6 @@
VITE_RUNTIME_ENV=production
VITE_ALLOW_INSECURE_DEV_LOGIN=false
VITE_DEV_API_PROXY_TARGET=http://backend:8000
# Default CTMS origin embedded in Desktop builds; users can override it in Desktop settings.
VITE_DESKTOP_SERVER_URL=https://ctms.example.com
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
VITE_HMR_CLIENT_PORT=
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
-22
View File
@@ -1,22 +0,0 @@
{
"schemaVersion": 1,
"defaultPlatformSigningMode": "signed",
"updaterSigningRequired": true,
"unsignedPlatformExceptions": [
{
"version": "0.1.0",
"status": "approved",
"macos": "ad-hoc",
"windows": "unsigned",
"distribution": "controlled",
"macosLocalExactTagFallback": true,
"windowsDelivery": "deferred-same-tag",
"updaterFeedActivation": "after-combined-platform-verification",
"githubReleaseAssetProfile": "installer-and-checksum-only",
"stagedEvidenceRetention": "private-until-updater-publish",
"updaterArtifactPublishTarget": "anonymous-https-origin",
"approvedOn": "2026-07-17",
"reason": "The v0.1.0 release owner cannot currently provide Apple Developer signing/notarization credentials or an organization Windows code-signing certificate, and hosted Windows build capacity is temporarily unavailable."
}
]
}
@@ -1,12 +0,0 @@
CTMS DESKTOP PLATFORM SIGNING WARNING
This release is distributed under the version-scoped v0.1.0 platform-signing exception.
- The macOS application uses an ad-hoc signature and is not Apple-notarized.
- The Windows application and installer have no Authenticode signature or RFC 3161 timestamp.
- macOS Gatekeeper and Windows SmartScreen warnings are expected.
- Tauri updater artifacts remain cryptographically signed and must pass updater feed verification.
- Platform availability may be staged; production latest.json remains withheld until macOS and Windows artifacts from the same tag pass combined verification.
- Install only after verifying the release tag, source commit, SHA256SUMS.txt, and DESKTOP-RELEASE-PROVENANCE.json through a trusted channel.
This exception does not apply to later versions unless they are separately approved in desktop-release-policy.json.
@@ -1,9 +0,0 @@
CTMS v0.1.0 WINDOWS RELEASE PENDING
This GitHub Release is the approved first stage of the v0.1.0 Desktop distribution.
- macOS Universal artifacts are available and use an ad-hoc platform signature.
- Windows x64 NSIS artifacts are not yet available.
- Windows must later be built from the same immutable v0.1.0 tag and Git commit.
- Production latest.json is intentionally withheld until Windows NotSigned artifacts and the combined updater feed pass verification.
- Do not interpret this staged release as a complete cross-platform automatic-update release.
-3
View File
@@ -15,10 +15,7 @@
"desktop:build": "tauri build",
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
"desktop:build:macos-release": "tauri build --target universal-apple-darwin --bundles app,dmg",
"desktop:build:macos-unsigned-release": "tauri build --target universal-apple-darwin --bundles app,dmg --config '{\"bundle\":{\"macOS\":{\"signingIdentity\":\"-\"}}}'",
"desktop:build:windows-release": "tauri build --bundles nsis",
"desktop:release-policy:check": "node scripts/resolve-desktop-release-policy.mjs",
"desktop:release-provenance:create": "node scripts/create-desktop-release-provenance.mjs",
"desktop:update-feed:create": "node scripts/create-desktop-update-feed.mjs",
"desktop:bundle:dmg": "tauri build --bundles dmg",
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
@@ -1,79 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const args = process.argv.slice(2);
const value = (name) => {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
};
const policy = await loadDesktopReleasePolicy();
const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy);
const tag = value("--tag") || process.env.GITHUB_REF_NAME;
const commit = value("--commit") || process.env.GITHUB_SHA;
const macosSigning = value("--macos-signing");
const windowsSigning = value("--windows-signing");
const releaseStage = value("--stage") || "complete";
const outputPath = resolve(
frontendDir,
value("--output") || "src-tauri/target/desktop-release-feed/DESKTOP-RELEASE-PROVENANCE.json",
);
const failures = [];
const assert = (condition, message) => {
if (!condition) failures.push(message);
};
assert(tag === `v${packageInfo.version}`, `Release provenance tag must be v${packageInfo.version}.`);
assert(/^[0-9a-f]{40}$/i.test(commit || ""), "Release provenance commit must be a full 40-character SHA.");
assert(macosSigning === resolution.macosSigning, `macOS signing mode must be ${resolution.macosSigning}.`);
assert(windowsSigning === resolution.windowsSigning, `Windows signing mode must be ${resolution.windowsSigning}.`);
assert(["complete", "macos-first"].includes(releaseStage), "Release provenance stage must be complete or macos-first.");
if (releaseStage === "macos-first") {
assert(
resolution.macosLocalExactTagFallback === true && resolution.windowsDelivery === "deferred-same-tag",
`v${packageInfo.version} does not approve a staged local macOS-first release.`,
);
}
if (failures.length > 0) {
throw new Error(`Desktop release provenance creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
}
const provenance = {
schemaVersion: 1,
product: "CTMS Desktop",
version: packageInfo.version,
tag,
commit,
platformSigningMode: resolution.platformSigningMode,
platformSigning: {
macos: macosSigning,
windows: windowsSigning,
},
updaterSigning: "required-and-verified",
artifactLabel: resolution.artifactLabel,
distribution: resolution.distribution,
stage: releaseStage,
platformAvailability:
releaseStage === "macos-first"
? { macos: "available", windows: "pending-same-tag" }
: { macos: "available", windows: "available" },
updaterFeedActivation:
releaseStage === "macos-first" ? "withheld-pending-combined-verification" : "combined-platform-verification-required",
warnings: resolution.warningRequired
? [
"macOS is ad-hoc signed and not Apple-notarized; Gatekeeper warnings are expected.",
"Windows is not Authenticode-signed or RFC 3161 timestamped; SmartScreen warnings are expected.",
"Verify the release tag, commit, updater signatures, and SHA256SUMS.txt through a trusted channel before installation.",
]
: [],
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(provenance, null, 2)}\n`);
console.log(`Desktop release provenance created at ${outputPath}`);
-112
View File
@@ -1,112 +0,0 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
export const desktopReleasePolicyPath = resolve(frontendDir, "desktop-release-policy.json");
const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
const validatePolicy = (policy) => {
const failures = [];
const assert = (condition, message) => {
if (!condition) failures.push(message);
};
assert(policy?.schemaVersion === 1, "desktop release policy schemaVersion must be 1.");
assert(
policy?.defaultPlatformSigningMode === "signed",
"desktop release policy must default platform signing to signed.",
);
assert(policy?.updaterSigningRequired === true, "desktop release policy must always require updater signing.");
assert(
Array.isArray(policy?.unsignedPlatformExceptions),
"desktop release policy unsignedPlatformExceptions must be an array.",
);
const versions = new Set();
for (const exception of policy?.unsignedPlatformExceptions || []) {
const prefix = `unsigned platform exception ${exception?.version || "<missing>"}`;
assert(semverPattern.test(exception?.version || ""), `${prefix} must use an exact X.Y.Z version.`);
assert(!versions.has(exception?.version), `${prefix} is duplicated.`);
versions.add(exception?.version);
assert(exception?.status === "approved", `${prefix} must have status approved.`);
assert(exception?.macos === "ad-hoc", `${prefix} must constrain macOS to ad-hoc signing.`);
assert(exception?.windows === "unsigned", `${prefix} must constrain Windows to unsigned.`);
assert(exception?.distribution === "controlled", `${prefix} must constrain distribution to controlled.`);
if (exception?.version === "0.1.0") {
assert(exception?.macosLocalExactTagFallback === true, `${prefix} must explicitly approve the local exact-tag macOS fallback.`);
assert(exception?.windowsDelivery === "deferred-same-tag", `${prefix} must defer Windows only from the same tag.`);
assert(
exception?.updaterFeedActivation === "after-combined-platform-verification",
`${prefix} must withhold the production updater feed until both platforms are verified.`,
);
assert(
exception?.githubReleaseAssetProfile === "installer-and-checksum-only",
`${prefix} must keep the user-facing GitHub Release limited to installers and their checksum manifest.`,
);
assert(
exception?.stagedEvidenceRetention === "private-until-updater-publish",
`${prefix} must retain updater artifacts and release evidence privately until updater publication.`,
);
assert(
exception?.updaterArtifactPublishTarget === "anonymous-https-origin",
`${prefix} must publish updater artifacts to an anonymous HTTPS origin.`,
);
}
assert(/^\d{4}-\d{2}-\d{2}$/.test(exception?.approvedOn || ""), `${prefix} must record an approval date.`);
assert(
typeof exception?.reason === "string" && exception.reason.trim().length >= 30,
`${prefix} must record a substantive reason.`,
);
}
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
};
export const loadDesktopReleasePolicy = async () => {
const policy = JSON.parse(await readFile(desktopReleasePolicyPath, "utf8"));
validatePolicy(policy);
return policy;
};
export const resolveDesktopReleasePolicy = (version, policy) => {
if (!semverPattern.test(version || "")) {
throw new Error(`Desktop release version must use exact X.Y.Z semver; found ${version || "<missing>"}.`);
}
const exception = policy.unsignedPlatformExceptions.find(
(candidate) => candidate.version === version && candidate.status === "approved",
);
if (!exception) {
return {
version,
platformSigningMode: "signed",
macosSigning: "apple-developer",
windowsSigning: "authenticode",
artifactLabel: "SIGNED-PLATFORM",
distribution: "formal",
warningRequired: false,
};
}
return {
version,
platformSigningMode: "unsigned-exception",
macosSigning: exception.macos,
windowsSigning: exception.windows,
artifactLabel: "UNSIGNED-PLATFORM",
distribution: exception.distribution,
warningRequired: true,
macosLocalExactTagFallback: exception.macosLocalExactTagFallback === true,
windowsDelivery: exception.windowsDelivery,
updaterFeedActivation: exception.updaterFeedActivation,
githubReleaseAssetProfile: exception.githubReleaseAssetProfile,
stagedEvidenceRetention: exception.stagedEvidenceRetention,
updaterArtifactPublishTarget: exception.updaterArtifactPublishTarget,
exception,
};
};
@@ -1,47 +0,0 @@
import { appendFile, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const args = process.argv.slice(2);
const value = (name) => {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
};
const policy = await loadDesktopReleasePolicy();
const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy);
const expectedTag = `v${packageInfo.version}`;
if (args.includes("--require-tag")) {
if (process.env.GITHUB_REF_TYPE !== "tag" || process.env.GITHUB_REF_NAME !== expectedTag) {
throw new Error(
`Desktop release policy must be resolved from tag ${expectedTag}; found ${process.env.GITHUB_REF_TYPE || "<missing>"} ${process.env.GITHUB_REF_NAME || "<missing>"}.`,
);
}
}
const outputs = {
version: resolution.version,
platform_signing_mode: resolution.platformSigningMode,
macos_signing: resolution.macosSigning,
windows_signing: resolution.windowsSigning,
artifact_label: resolution.artifactLabel,
distribution: resolution.distribution,
warning_required: String(resolution.warningRequired),
updater_signing_required: String(policy.updaterSigningRequired),
};
const githubOutput = value("--github-output");
if (githubOutput) {
await appendFile(githubOutput, `${Object.entries(outputs).map(([key, output]) => `${key}=${output}`).join("\n")}\n`);
}
console.log("Desktop release policy check passed.");
console.log(` version: ${resolution.version}`);
console.log(` platform signing mode: ${resolution.platformSigningMode}`);
console.log(` macOS: ${resolution.macosSigning}`);
console.log(` Windows: ${resolution.windowsSigning}`);
console.log(" updater signing: required");
@@ -2,13 +2,10 @@ import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = [];
const env = process.env;
@@ -16,10 +13,6 @@ const fullShaPattern = /^[0-9a-f]{40}$/i;
const expectedTag = `v${packageInfo.version}`;
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true";
const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true";
const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE;
const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM;
const requiredUpdaterEnv = ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"];
const requiredMacosEnv = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID"];
const requiredWindowsEnv = ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD"];
@@ -45,7 +38,7 @@ const gitMaybe = (args) => {
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for desktop release readiness.`);
assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
};
const validateBaseUrl = () => {
@@ -70,25 +63,6 @@ const validateBaseUrl = () => {
);
};
const validateClientServerUrl = () => {
const raw = env.VITE_DESKTOP_SERVER_URL;
requireEnv("VITE_DESKTOP_SERVER_URL");
if (!raw) return;
let url;
try {
url = new URL(raw);
} catch (error) {
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
};
const validateWindowsTimestampUrl = () => {
const raw = env.WINDOWS_TIMESTAMP_URL;
requireEnv("WINDOWS_TIMESTAMP_URL");
@@ -121,54 +95,19 @@ if (headSha && env.VITE_BUILD_COMMIT) {
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
}
assert(
requiresMacosSigning || requiresWindowsSigning,
"Release readiness requires REQUIRE_DESKTOP_SIGNING=true or REQUIRE_WINDOWS_SIGNING=true.",
);
assert(
!(requiresMacosSigning && requiresWindowsSigning),
"macOS and Windows signing readiness must be checked in their native jobs.",
);
assert(
desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows",
"DESKTOP_RELEASE_PLATFORM must be macos or windows.",
);
assert(
desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode,
`DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`,
);
assert(
desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning,
"Release readiness requires REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.",
);
for (const name of requiredUpdaterEnv) {
requireEnv(name);
}
if (desktopPlatformSigningMode === "signed") {
assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE.");
assert(
(desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) ||
(desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning),
"Signed readiness must enable only the matching native platform signing requirement.",
);
}
if (desktopPlatformSigningMode === "unsigned-exception") {
assert(
desktopReleaseResolution.platformSigningMode === "unsigned-exception",
`v${packageInfo.version} has no approved unsigned platform release exception.`,
);
assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true.");
assert(
!requiresMacosSigning && !requiresWindowsSigning,
"Unsigned platform readiness must not claim Apple or Windows platform signing.",
);
if (desktopReleasePlatform === "macos") {
assert(process.platform === "darwin", "The macOS ad-hoc readiness check must run on macOS.");
}
if (desktopReleasePlatform === "windows") {
assert(process.platform === "win32", "The unsigned Windows readiness check must run on Windows.");
}
}
if (requiresMacosSigning) {
assert(process.platform === "darwin", "Signed macOS desktop release readiness must run on macOS.");
for (const name of requiredMacosEnv) {
@@ -192,7 +131,6 @@ if (requiresWindowsSigning) {
}
validateBaseUrl();
validateClientServerUrl();
if (failures.length > 0) {
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
+1 -72
View File
@@ -62,7 +62,7 @@ const verifyTauriConfig = async () => {
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
assert(
tauriConfig.bundle?.createUpdaterArtifacts === true,
"Tauri must create signed updater artifacts for formal desktop release builds.",
"Tauri must create updater artifacts for signed desktop release builds.",
);
assert(
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
@@ -387,28 +387,12 @@ const verifyUpdaterBoundary = async () => {
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
};
const verifyDesktopServerConfigurationBoundary = async () => {
const source = await readFile(resolve(sourceDir, "runtime/desktopServerConfig.ts"), "utf8");
assert(
source.includes("import.meta.env.VITE_DESKTOP_SERVER_URL"),
"Desktop server defaults must come from VITE_DESKTOP_SERVER_URL.",
);
assert(!source.includes("ctms.huapont.cn"), "The production CTMS origin must not be hard-coded in Desktop runtime source.");
assert(
source.includes("getStoredDesktopServerUrl() || getDefaultDesktopServerUrl()"),
"A manually stored Desktop server URL must override the build-time default.",
);
};
const verifyWorkflowGates = async () => {
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
const requiredScripts = [
"desktop:build:macos-release",
"desktop:build:macos-unsigned-release",
"desktop:build:windows-release",
"desktop:release-policy:check",
"desktop:release-provenance:create",
"desktop:update-feed:create",
"desktop:update-feed:check",
"desktop:release-readiness:check",
@@ -419,47 +403,6 @@ const verifyWorkflowGates = async () => {
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
}
const provenanceSource = await readFile(resolve(frontendDir, "scripts/create-desktop-release-provenance.mjs"), "utf8");
assert(provenanceSource.includes('"macos-first"'), "Release provenance must support the approved v0.1.0 macOS-first stage.");
assert(
provenanceSource.includes("withheld-pending-combined-verification"),
"Staged macOS provenance must record that updater feed activation is withheld.",
);
const windowsPendingNotice = await readFile(resolve(frontendDir, "desktop-release-windows-pending.txt"), "utf8");
assert(
windowsPendingNotice.includes("same immutable v0.1.0 tag") && windowsPendingNotice.includes("latest.json is intentionally withheld"),
"The staged v0.1.0 private release evidence must include an explicit Windows-pending and updater-feed notice.",
);
const releasePolicy = await readJson(resolve(frontendDir, "desktop-release-policy.json"));
assert(releasePolicy.schemaVersion === 1, "Desktop release policy schemaVersion must be 1.");
assert(
releasePolicy.defaultPlatformSigningMode === "signed",
"Desktop release policy must default future versions to signed platform releases.",
);
assert(releasePolicy.updaterSigningRequired === true, "Desktop release policy must require updater signing.");
const currentUnsignedException = releasePolicy.unsignedPlatformExceptions?.find(
(exception) => exception.version === packageInfo.version,
);
if (packageInfo.version === "0.1.0") {
assert(Boolean(currentUnsignedException), "Desktop release policy must record the approved v0.1.0 exception.");
assert(
currentUnsignedException?.macosLocalExactTagFallback === true &&
currentUnsignedException?.windowsDelivery === "deferred-same-tag" &&
currentUnsignedException?.updaterFeedActivation === "after-combined-platform-verification" &&
currentUnsignedException?.githubReleaseAssetProfile === "installer-and-checksum-only" &&
currentUnsignedException?.stagedEvidenceRetention === "private-until-updater-publish" &&
currentUnsignedException?.updaterArtifactPublishTarget === "anonymous-https-origin",
"The v0.1.0 exception must constrain local macOS staging, deferred Windows delivery, visible GitHub assets, private evidence retention, and updater publication.",
);
}
if (currentUnsignedException) {
assert(
currentUnsignedException.macos === "ad-hoc" && currentUnsignedException.windows === "unsigned",
`The v${packageInfo.version} exception must use macOS ad-hoc and unsigned Windows modes.`,
);
}
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [
"npm run version:check",
@@ -478,7 +421,6 @@ const verifyWorkflowGates = async () => {
}
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
assert(workflow.includes("VITE_DESKTOP_SERVER_URL"), "Client quality gates workflow must inject VITE_DESKTOP_SERVER_URL.");
assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs.");
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
@@ -487,18 +429,7 @@ const verifyWorkflowGates = async () => {
"runs-on: windows-latest",
"REQUIRE_DESKTOP_SIGNING",
"REQUIRE_WINDOWS_SIGNING",
"REQUIRE_UPDATER_SIGNING",
"ALLOW_UNSIGNED_PLATFORM_RELEASE",
"DESKTOP_PLATFORM_SIGNING_MODE",
"desktop:release-policy:check",
"desktop:build:macos-unsigned-release",
"Signature=adhoc",
'"NotSigned"',
"UNSIGNED-PLATFORM",
"DESKTOP-RELEASE-PROVENANCE.json",
"--require-provenance",
"TAURI_SIGNING_PRIVATE_KEY",
"VITE_DESKTOP_SERVER_URL",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
@@ -532,7 +463,6 @@ const verifyWorkflowGates = async () => {
"runs-on: windows-latest",
"VITE_BUILD_CHANNEL",
"VITE_BUILD_COMMIT",
"VITE_DESKTOP_SERVER_URL",
"npm run release:env:check",
"npm run version:check",
"npm run runtime:check",
@@ -591,7 +521,6 @@ await verifySourceSafety();
await verifyNotificationBoundary();
await verifySessionBoundary();
await verifyUpdaterBoundary();
await verifyDesktopServerConfigurationBoundary();
await verifyOnlyOfficeBoundary();
await verifyWorkflowGates();
@@ -2,12 +2,9 @@ import { access, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = [];
const args = process.argv.slice(2);
@@ -24,11 +21,6 @@ const feedPath = resolve(
);
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const requiresProvenance = args.includes("--require-provenance");
const provenancePath =
optionValue("--provenance") ||
process.env.DESKTOP_RELEASE_PROVENANCE ||
(artifactDir ? resolve(artifactDir, "DESKTOP-RELEASE-PROVENANCE.json") : undefined);
const requiredPlatforms = new Set([
"darwin-aarch64",
"darwin-x86_64",
@@ -102,56 +94,6 @@ const assertManifestEntries = async (checksums) => {
}
};
const verifyProvenance = async (checksums) => {
if (!requiresProvenance) return;
if (!provenancePath) {
fail("Desktop release provenance path is required.");
return;
}
let provenance;
try {
provenance = JSON.parse(await readFile(provenancePath, "utf8"));
} catch (error) {
fail(`Cannot read desktop release provenance ${provenancePath}: ${error.message}`);
return;
}
assert(provenance.schemaVersion === 1, "Desktop release provenance schemaVersion must be 1.");
assert(provenance.version === packageInfo.version, "Desktop release provenance version must match the package version.");
assert(provenance.tag === `v${packageInfo.version}`, "Desktop release provenance tag must match the package version.");
assert(/^[0-9a-f]{40}$/i.test(provenance.commit || ""), "Desktop release provenance must contain a full commit SHA.");
assert(
provenance.platformSigningMode === desktopReleaseResolution.platformSigningMode,
`Desktop release provenance platformSigningMode must be ${desktopReleaseResolution.platformSigningMode}.`,
);
assert(
provenance.platformSigning?.macos === desktopReleaseResolution.macosSigning,
`Desktop release provenance macOS signing must be ${desktopReleaseResolution.macosSigning}.`,
);
assert(
provenance.platformSigning?.windows === desktopReleaseResolution.windowsSigning,
`Desktop release provenance Windows signing must be ${desktopReleaseResolution.windowsSigning}.`,
);
assert(
provenance.updaterSigning === "required-and-verified",
"Desktop release provenance must record required-and-verified updater signing.",
);
assert(
provenance.artifactLabel === desktopReleaseResolution.artifactLabel,
`Desktop release provenance artifactLabel must be ${desktopReleaseResolution.artifactLabel}.`,
);
if (desktopReleaseResolution.warningRequired) {
assert(
Array.isArray(provenance.warnings) && provenance.warnings.length >= 2,
"Unsigned platform release provenance must include installation trust warnings.",
);
}
if (artifactDir) {
await assertChecksum(checksums, provenancePath, "desktop release provenance");
}
};
let feed;
try {
feed = JSON.parse(await readFile(feedPath, "utf8"));
@@ -162,7 +104,6 @@ try {
if (feed) {
const checksums = await readChecksumManifest();
await assertManifestEntries(checksums);
await verifyProvenance(checksums);
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"];
+11 -86
View File
@@ -2,13 +2,10 @@ import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = [];
const allowedChannels = new Set(["dev", "main", "release", "local"]);
@@ -23,36 +20,6 @@ const isTagBuild = env.GITHUB_REF_TYPE === "tag";
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true";
const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true";
const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE;
const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM;
const isNativeDesktopRelease = Boolean(
desktopReleasePlatform ||
desktopPlatformSigningMode ||
requiresMacosSigning ||
requiresWindowsSigning ||
requiresUpdaterSigning,
);
const validateDesktopServerUrl = () => {
const raw = env.VITE_DESKTOP_SERVER_URL;
requireEnv("VITE_DESKTOP_SERVER_URL");
if (!raw) return;
let url;
try {
url = new URL(raw);
} catch (error) {
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
};
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
@@ -60,7 +27,7 @@ const assert = (condition, message) => {
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for this desktop release build.`);
assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
};
const gitHead = () => {
@@ -80,7 +47,6 @@ assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...al
if (isCi || isReleaseBuild) {
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
validateDesktopServerUrl();
}
if (env.GITHUB_SHA) {
@@ -108,59 +74,13 @@ assert(
"macOS and Windows signing requirements must be checked in their native jobs.",
);
if (isNativeDesktopRelease) {
assert(isReleaseBuild, "Native desktop release settings may only be used for release builds.");
assert(
desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning,
"Formal desktop releases must set REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.",
);
assert(
desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode,
`DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`,
);
assert(
desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows",
"DESKTOP_RELEASE_PLATFORM must be macos or windows for native desktop release builds.",
);
if (isCi) {
assert(isTagBuild, "Native desktop release candidate builds in CI must run from a release tag.");
}
}
if (requiresUpdaterSigning) {
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
}
if (desktopPlatformSigningMode === "signed") {
assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE.");
assert(
(desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) ||
(desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning),
"Signed native release jobs must enable only their matching platform signing requirement.",
);
}
if (desktopPlatformSigningMode === "unsigned-exception") {
assert(
desktopReleaseResolution.platformSigningMode === "unsigned-exception",
`v${packageInfo.version} has no approved unsigned platform release exception.`,
);
assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true.");
assert(
!requiresMacosSigning && !requiresWindowsSigning,
"Unsigned platform exceptions must not claim Apple or Windows platform signing.",
);
if (desktopReleasePlatform === "macos") {
assert(process.platform === "darwin", "The macOS ad-hoc release exception must run on macOS.");
}
if (desktopReleasePlatform === "windows") {
assert(process.platform === "win32", "The unsigned Windows release exception must run on Windows.");
}
}
if (requiresMacosSigning) {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
if (isCi) {
assert(isTagBuild, "Signed desktop release candidate builds in CI must run from a release tag.");
}
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
requireEnv("APPLE_ID");
requireEnv("APPLE_PASSWORD");
requireEnv("APPLE_TEAM_ID");
@@ -175,6 +95,11 @@ if (requiresMacosSigning) {
if (requiresWindowsSigning) {
assert(process.platform === "win32", "Signed Windows desktop release builds must run on Windows.");
if (isCi) {
assert(isTagBuild, "Signed Windows desktop release candidate builds in CI must run from a release tag.");
}
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
requireEnv("WINDOWS_CERTIFICATE");
requireEnv("WINDOWS_CERTIFICATE_PASSWORD");
requireEnv("WINDOWS_TIMESTAMP_URL");
-56
View File
@@ -1,56 +0,0 @@
import {
fetchCollaborationFile, fetchCollaborationMembers, fetchCollaborationCandidates,
upsertCollaborationMember, removeCollaborationMember, fetchCollaborationShareLink,
updateCollaborationShareLink, updateCollaborationFile, fetchCollaborationEditRequests,
resolveCollaborationEditRequest, transferCollaborationOwnership,
} from "./collaboration";
import {
fetchLedger, fetchLedgerMembers, fetchLedgerCandidates, setLedgerMember, removeLedgerMember,
fetchLedgerShareLink, updateLedgerShareLink, updateLedgerAccessSettings, fetchLedgerEditRequests,
resolveLedgerEditRequest, transferLedgerOwnership,
} from "./ledgers";
import type { CollaborationCandidate, CollaborationFile } from "../types/collaboration";
export type CollaborationAccessTarget = { scope: "project"; studyId: string } | { scope: "ledger" };
export type CollaborationAccessFile = Pick<CollaborationFile,
"id" | "title" | "owner_id" | "owner_name" | "file_type" | "status" | "generation" |
"can_manage" | "can_transfer_ownership" | "allow_export" | "allow_edit_request" | "allow_sheet_structure_edit"
>;
export type CollaborationAccessRole = "VIEWER" | "EDITOR" | "MANAGER";
export interface CollaborationAccessMember {
user_id: string;
full_name: string;
email: string;
role: CollaborationAccessRole;
is_active?: boolean;
protected_label?: string | null;
}
export type CollaborationAccessCandidate = CollaborationCandidate;
type AccessPatch = Partial<Pick<CollaborationAccessFile, "allow_export" | "allow_edit_request" | "allow_sheet_structure_edit">>;
export const createCollaborationAccessApi = (target: CollaborationAccessTarget) => {
const project = target.scope === "project" ? target.studyId : null;
return {
file: (id: string) => project !== null ? fetchCollaborationFile(project, id) : fetchLedger(id),
members: (id: string) => project !== null ? fetchCollaborationMembers(project, id) : fetchLedgerMembers(id),
candidates: (id: string) => project !== null ? fetchCollaborationCandidates(project) : fetchLedgerCandidates(id),
setMember: (id: string, payload: { user_id: string; role: CollaborationAccessRole }) => {
if (project === null) return setLedgerMember(id, payload.user_id, payload.role);
if (payload.role === "VIEWER") return Promise.reject(new Error("项目协作不支持此授权角色"));
return upsertCollaborationMember(project, id, { ...payload, role: payload.role });
},
removeMember: (id: string, userId: string) => project !== null
? removeCollaborationMember(project, id, userId) : removeLedgerMember(id, userId),
share: (id: string) => project !== null ? fetchCollaborationShareLink(project, id) : fetchLedgerShareLink(id),
updateShare: (id: string, payload: Parameters<typeof updateCollaborationShareLink>[2]) => project !== null
? updateCollaborationShareLink(project, id, payload) : updateLedgerShareLink(id, payload),
updateFile: (file: CollaborationAccessFile, payload: AccessPatch) => project !== null
? updateCollaborationFile(project, file.id, payload)
: updateLedgerAccessSettings(file.id, { ...payload, generation: file.generation }),
requests: (id: string) => project !== null ? fetchCollaborationEditRequests(project, id) : fetchLedgerEditRequests(id),
resolveRequest: (id: string, requestId: string, status: "APPROVED" | "REJECTED") => project !== null
? resolveCollaborationEditRequest(project, id, requestId, status) : resolveLedgerEditRequest(id, requestId, status),
transfer: (id: string, userId: string) => project !== null
? transferCollaborationOwnership(project, id, userId) : transferLedgerOwnership(id, userId),
};
};
-19
View File
@@ -1,19 +0,0 @@
import type { CollaborationFile } from "../types/collaboration";
import type { CollaborationAccessTarget } from "./collaborationAccess";
import { fetchCollaborationFile, fetchCollaborationRevisions, updateCollaborationRevision, restoreCollaborationRevision } from "./collaboration";
import { fetchLedger, fetchLedgerRevisions, updateLedgerRevision, restoreLedgerRevision } from "./ledgers";
export type CollaborationHistoryFile = Pick<CollaborationFile,
"id" | "title" | "owner_name" | "file_type" | "extension" | "folder_id" | "status" | "generation" |
"current_revision_id" | "current_revision_no" | "current_revision_file_size" | "current_revision_mime_type" |
"current_revision_created_at" | "can_edit" | "can_manage" | "can_export"
>;
export const createCollaborationHistoryApi = (target: CollaborationAccessTarget) => ({
file: (id: string) => target.scope === "ledger" ? fetchLedger(id) : fetchCollaborationFile(target.studyId, id),
revisions: (id: string) => target.scope === "ledger" ? fetchLedgerRevisions(id) : fetchCollaborationRevisions(target.studyId, id),
name: (id: string, revisionId: string, payload: { change_summary: string }) => target.scope === "ledger"
? updateLedgerRevision(id, revisionId, payload) : updateCollaborationRevision(target.studyId, id, revisionId, payload),
restore: (file: CollaborationHistoryFile, revisionId: string) => target.scope === "ledger"
? restoreLedgerRevision(file.id, revisionId, file.generation) : restoreCollaborationRevision(target.studyId, file.id, revisionId),
});
-49
View File
@@ -1,49 +0,0 @@
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./axios";
import type { Ledger, LedgerCandidate, LedgerMember, LedgerRole, LedgerSettings } from "../types/ledger";
import type { CollaborationEditorConfig, CollaborationRevision, CollaborationEditRequest, CollaborationShareLink } from "../types/collaboration";
import type { updateCollaborationShareLink } from "./collaboration";
import type { OnlyOfficePreviewConfig } from "../types/onlyoffice";
const base = "/api/v1/ledgers";
const online = { cache: false, disableRequestDedupe: true, disableNetworkRetry: true, suppressErrorMessage: true } as const;
export const fetchLedgers = () => apiGet<Ledger[]>(base, online);
export const initializeLedgers = () => apiPost<Ledger[]>(`${base}/initialize`, {}, online);
export const fetchLedger = (id: string) => apiGet<Ledger>(`${base}/${id}`, online);
export const updateLedger = (id: string, payload: LedgerSettings) => apiPatch<Ledger>(`${base}/${id}`, payload, online);
export const importLedger = (id: string, file: File, generation: number) => {
const form = new FormData();
form.append("file", file);
form.append("generation", String(generation));
return apiPost<Ledger>(`${base}/${id}/import`, form, online);
};
export const fetchLedgerMembers = (id: string) => apiGet<LedgerMember[]>(`${base}/${id}/members`, online);
export const fetchLedgerCandidates = (id: string, keyword = "") =>
apiGet<LedgerCandidate[]>(`${base}/${id}/candidates`, { ...online, params: { keyword } });
export const setLedgerMember = (id: string, userId: string, role: LedgerRole) =>
apiPut(`${base}/${id}/members`, { user_id: userId, role }, online);
export const removeLedgerMember = (id: string, userId: string) => apiDelete(`${base}/${id}/members/${userId}`, online);
export const updateLedgerAccessSettings = (id: string, payload: {
generation: number; allow_export?: boolean; allow_edit_request?: boolean; allow_sheet_structure_edit?: boolean;
}) => apiPatch<Ledger>(`${base}/${id}/access-settings`, payload, online);
export const fetchLedgerShareLink = (id: string) => apiGet<CollaborationShareLink>(`${base}/${id}/share-link`, online);
export const updateLedgerShareLink = (id: string, payload: Parameters<typeof updateCollaborationShareLink>[2]) =>
apiPut<CollaborationShareLink>(`${base}/${id}/share-link`, payload, online);
export const createLedgerEditRequest = (id: string) => apiPost<CollaborationEditRequest>(`${base}/${id}/edit-requests`, {}, online);
export const fetchLedgerEditRequests = (id: string) => apiGet<CollaborationEditRequest[]>(`${base}/${id}/edit-requests`, online);
export const resolveLedgerEditRequest = (id: string, requestId: string, status: "APPROVED" | "REJECTED") =>
apiPost<CollaborationEditRequest>(`${base}/${id}/edit-requests/${requestId}/resolve`, { status }, online);
export const transferLedgerOwnership = (id: string, userId: string) =>
apiPost<Ledger>(`${base}/${id}/transfer-ownership`, { new_owner_id: userId }, online);
export const fetchLedgerEditorConfig = (id: string) => apiGet<CollaborationEditorConfig>(`${base}/${id}/editor-config`, online);
export const checkLedgerDownload = (id: string, fileType: string) =>
apiPost(`${base}/${id}/downloads`, { file_type: fileType }, online);
export const fetchLedgerRevisions = (id: string) => apiGet<CollaborationRevision[]>(`${base}/${id}/revisions`, online);
export const updateLedgerRevision = (id: string, revisionId: string, payload: { change_summary: string }) =>
apiPatch<CollaborationRevision>(`${base}/${id}/revisions/${revisionId}`, payload, online);
export const downloadLedgerRevision = (id: string, revisionId: string) =>
apiGet<Blob>(`${base}/${id}/revisions/${revisionId}/download`, { ...online, responseType: "blob" });
export const restoreLedgerRevision = (id: string, revisionId: string, generation: number) =>
apiPost<CollaborationRevision>(`${base}/${id}/revisions/${revisionId}/restore`, { generation }, online);
export const fetchLedgerRevisionPreview = (id: string, revisionId: string) =>
apiGet<OnlyOfficePreviewConfig>(`${base}/${id}/revisions/${revisionId}/preview-config`, online);
@@ -1,36 +0,0 @@
<template>
<span class="entry-count-badge">
<span class="entry-count-badge__dot" aria-hidden="true"></span>
<span><slot /></span>
</span>
</template>
<style scoped>
.entry-count-badge {
display: inline-flex;
align-items: center;
flex-shrink: 0;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
font-size: 11.5px;
font-weight: 700;
white-space: nowrap;
}
.entry-count-badge__dot {
width: 5px;
height: 5px;
flex-shrink: 0;
border-radius: 50%;
background: #3b82f6;
}
:global([data-ctms-theme="dark"]) .entry-count-badge {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
</style>
@@ -47,12 +47,11 @@ describe("PermissionIpLocations", () => {
expect(source).not.toContain('all: "近 90 天"');
});
it("keeps fullscreen animation sharp without dirty-rect artifacts or extra canvas layers", () => {
it("keeps fullscreen animation sharp without multiplying large canvas layers", () => {
const source = readSource();
expect(source).toContain('v-if="!fullscreenVisible"');
expect(source).toContain("useDirtyRect: false");
expect(source).not.toContain("useDirtyRect: true");
expect(source).toContain("useDirtyRect: true");
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
expect(source).not.toContain("getDevicePixelRatio() * 1.5");
expect(source).not.toContain("zlevel:");
@@ -223,9 +223,8 @@ const mapLoadError = ref("");
const mapReady = ref(false);
const lastUpdatedAt = ref<Date | null>(null);
const getDevicePixelRatio = () => (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
// Animated lines/effectScatter leave cleared rectangles over the geo layer when dirty-rect rendering is enabled.
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: false };
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: false };
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: true };
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: true };
const chartUpdateOptions = { notMerge: true };
const chartAutoresize = { throttle: 120 };
const fullscreenVisible = ref(false);
@@ -1,97 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import AccessDialog from "./CollaborationAccessDialog.vue";
import { createCollaborationAccessApi, type CollaborationAccessFile, type CollaborationAccessTarget } from "../../api/collaborationAccess";
const api = vi.hoisted(() => ({
file: vi.fn(), members: vi.fn(), candidates: vi.fn(), requests: vi.fn(), share: vi.fn(),
setMember: vi.fn(), removeMember: vi.fn(), updateFile: vi.fn(), updateShare: vi.fn(), resolveRequest: vi.fn(), transfer: vi.fn(),
}));
vi.mock("../../api/collaborationAccess", () => ({ createCollaborationAccessApi: vi.fn(() => api) }));
vi.mock("../../runtime", () => ({ clientRuntime: { apiBaseUrl: () => "http://localhost:8888" } }));
vi.mock("../../composables/useProjectNotifications", () => ({ notifyProjectNotificationsChanged: vi.fn() }));
vi.mock("../../utils/apiErrorMessage", () => ({ getApiErrorMessage: async (_error: unknown, fallback: string) => fallback }));
const file: CollaborationAccessFile = {
id: "file-a", title: "台账.xlsx", owner_id: "owner", owner_name: "Owner", file_type: "cell",
status: "ACTIVE", generation: 4, can_manage: true, can_transfer_ownership: true,
allow_export: false, allow_edit_request: true, allow_sheet_structure_edit: false,
};
const owner = { user_id: "owner", full_name: "Owner", email: "owner@example.com", role: "MANAGER", protected_label: "所有者" };
const candidate = { user_id: "candidate", full_name: "Candidate", email: "candidate@example.com", role_in_study: "系统账号", can_be_editor: true, can_be_manager: true };
const share = { enabled: false, has_password: false, access_mode: "VIEW", expiry_policy: "SEVEN_DAYS", share_path: "/collaboration/share", share_token: null };
let wrapper: ReturnType<typeof mount> | undefined;
const render = (target: CollaborationAccessTarget = { scope: "ledger" }) => {
wrapper = mount(AccessDialog, { props: { file, target }, global: {
directives: { loading: () => {} },
stubs: {
ElDialog: { props: ["modelValue", "title"], template: '<div v-if="modelValue" role="dialog"><slot name="header" />{{ title }}<slot /><slot name="footer" /></div>' },
ElButton: { template: '<button><slot /></button>' }, ElAvatar: { template: '<span><slot /></span>' },
ElTag: { template: '<span><slot /></span>' }, ElIcon: { template: '<span><slot /></span>' },
ElEmpty: true, ElInput: true,
ElSelect: { props: ["modelValue"], emits: ["update:modelValue", "change"], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><slot /></select>' },
ElOption: { props: ["label", "value"], template: '<option :value="value">{{ label }}</option>' },
ElSwitch: { props: ["modelValue", "disabled"], emits: ["update:modelValue", "change"], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target.checked); $emit(\'change\', $event.target.checked)" />' },
},
} });
return wrapper;
};
describe("shared collaboration access dialog", () => {
beforeEach(() => {
vi.clearAllMocks();
api.file.mockResolvedValue({ data: { ...file } });
api.members.mockResolvedValue({ data: [owner] });
api.candidates.mockResolvedValue({ data: [candidate] });
api.requests.mockResolvedValue({ data: [] });
api.share.mockResolvedValue({ data: share });
api.setMember.mockResolvedValue({});
});
afterEach(() => { wrapper?.unmount(); wrapper = undefined; });
it.each(["ledger", "project"] as const)("uses the same picker with the %s account directory and role boundary", async (scope) => {
const target: CollaborationAccessTarget = scope === "project" ? { scope, studyId: "study-a" } : { scope };
const page = render(target);
await flushPromises();
expect(createCollaborationAccessApi).toHaveBeenCalledWith(target);
expect(page.get(".access-member-row").text()).toContain("所有者");
expect(page.find(".access-member-remove").exists()).toBe(false);
expect(page.get('[aria-label="启用匿名访问"]').element).toHaveProperty("checked", false);
await page.get(".access-section__manage-button").trigger("click");
expect(page.get('[role="listbox"]').attributes("aria-label")).toBe(scope === "ledger" ? "可添加的系统账号" : "可添加的项目成员");
const select = page.get('[aria-label="文件授权角色"]');
expect(select.find('option[value="VIEWER"]').exists()).toBe(scope === "ledger");
if (scope === "ledger") await select.setValue("VIEWER");
await page.get(".member-picker-contact").trigger("click");
await page.findAll(".member-picker-selection__footer button").find((button) => button.text() === "确定")!.trigger("click");
await flushPromises();
expect(api.setMember).toHaveBeenCalledWith("file-a", { user_id: "candidate", role: scope === "ledger" ? "VIEWER" : "EDITOR" });
expect(page.emitted("changed")).toBeTruthy();
});
it("uses the refreshed generation after a share change before saving file permissions", async () => {
const page = render();
await flushPromises();
api.updateShare.mockResolvedValue({ data: { ...share, enabled: true, share_token: "test-token" } });
api.file.mockResolvedValue({ data: { ...file, generation: 5 } });
await page.get('[aria-label="启用匿名访问"]').setValue(true);
await flushPromises();
expect(api.updateShare).toHaveBeenCalledWith("file-a", expect.objectContaining({ enabled: true, password_mode: "KEEP" }));
await page.get(".access-setting-navigation").trigger("click");
api.updateFile.mockResolvedValue({ data: { ...file, generation: 6, allow_export: true } });
await page.get(".access-permission-row input").setValue(true);
await flushPromises();
expect(api.updateFile).toHaveBeenCalledWith(expect.objectContaining({ generation: 5 }), { allow_export: true });
});
it("closes access controls when a removed manager no longer has access", async () => {
api.members.mockResolvedValue({ data: [{ ...owner, user_id: "manager", protected_label: null }] });
const page = render();
await flushPromises();
api.removeMember.mockResolvedValue({});
api.members.mockRejectedValue({ response: { status: 404 } });
await page.get(".access-member-remove").trigger("click");
await flushPromises();
expect(page.find('[role="dialog"]').exists()).toBe(false);
expect(page.emitted("changed")).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,104 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import HistoryDialog from "./CollaborationHistoryDialog.vue";
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
import { downloadLedgerRevision } from "../../api/ledgers";
import { prepareSaveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const state = vi.hoisted(() => ({
api: { file: vi.fn(), revisions: vi.fn(), name: vi.fn(), restore: vi.fn() },
resolve: vi.fn(() => ({ href: "/ledger-preview" })), prompt: vi.fn(), confirm: vi.fn(),
}));
vi.mock("../../api/collaborationHistory", () => ({ createCollaborationHistoryApi: () => state.api }));
vi.mock("../../api/collaboration", () => ({ copyCollaborationRevision: vi.fn(), deleteCollaborationRevision: vi.fn() }));
vi.mock("../../api/ledgers", () => ({ downloadLedgerRevision: vi.fn(), importLedger: vi.fn() }));
vi.mock("../../runtime", () => ({ prepareSaveFile: vi.fn(), pickFiles: vi.fn() }));
vi.mock("../../utils/fileTaskFeedback", () => ({ saveFileWithFeedback: vi.fn() }));
vi.mock("../../store/auth", () => ({ useAuthStore: () => ({ user: { id: "u", full_name: "Owner" } }) }));
vi.mock("vue-router", () => ({ useRouter: () => ({ resolve: state.resolve }) }));
vi.mock("element-plus", () => ({ ElMessage: { error: vi.fn(), success: vi.fn() }, ElMessageBox: { prompt: state.prompt, confirm: state.confirm } }));
vi.mock("./CollaborationSaveAsDialog.vue", () => ({ default: { template: "<div />" } }));
const file: CollaborationHistoryFile = { id: "ledger-a", title: "台账.xlsx", extension: "xlsx", file_type: "cell", status: "ACTIVE", generation: 7, can_manage: true, can_edit: true, can_export: true, current_revision_id: "r2", owner_name: "Owner" };
const revisions = [
{ id: "r2", revision_no: 2, source: "FORCE_SAVE", created_at: "2026-09-03T01:00:00Z", file_size: 200, mime_type: "application/xlsx", change_summary: "", created_by_name: "Owner" },
{ id: "r1", revision_no: 1, source: "IMPORT", created_at: "2026-09-02T01:00:00Z", file_size: 100, mime_type: "application/xlsx", change_summary: "提交版", created_by_name: "Owner" },
];
let wrapper: ReturnType<typeof mount> | undefined;
const render = (overrides = {}, scope: "project" | "ledger" = "ledger") => {
const current = { ...file, ...overrides };
state.api.file.mockResolvedValue({ data: current });
wrapper = mount(HistoryDialog, { props: { file: current, target: scope === "ledger" ? { scope } : { scope, studyId: "study-a" }, canCreate: true }, global: {
directives: { loading: () => {} }, stubs: {
ElDialog: { props: ["modelValue"], template: '<div v-if="modelValue"><slot name="header"/><slot/></div>' },
ElButton: { template: '<button><slot/></button>' }, ElAvatar: { template: '<span><slot/></span>' },
ElTag: { template: '<span><slot/></span>' }, ElIcon: { template: '<span><slot/></span>' }, ElTooltip: { template: '<span><slot/></span>' }, ElEmpty: true,
ElDropdown: { name: "ElDropdown", emits: ["command"], template: '<div><slot/><slot name="dropdown"/></div>' },
ElDropdownMenu: { template: '<div><slot/></div>' }, ElDropdownItem: { template: '<span><slot/></span>' },
ElSelect: { props: ["modelValue"], emits: ["update:modelValue"], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot/></select>' },
ElOption: { props: ["value", "label"], template: '<option :value="value">{{ label }}</option>' },
ElSwitch: { props: ["modelValue"], emits: ["update:modelValue"], template: '<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)"/>' },
},
} });
return wrapper;
};
describe("shared project and ledger history", () => {
beforeEach(() => {
vi.clearAllMocks(); vi.useFakeTimers();
state.api.revisions.mockResolvedValue({ data: revisions.map(item => ({ ...item })) });
state.prompt.mockResolvedValue({ value: "复核版" }); state.confirm.mockResolvedValue("confirm");
});
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
it.each(["project", "ledger"] as const)("filters and names versions in the same %s history UI", async (scope) => {
const page = render({}, scope); await flushPromises();
expect(page.findAll(".history-day-group")).toHaveLength(2);
await page.get(".history-described-filter input").setValue(true);
expect(page.findAll(".history-row")).toHaveLength(1);
await page.get(".history-source-filter").setValue("FORCE_SAVE");
expect(page.findAll(".history-row")).toHaveLength(0);
await page.get(".history-described-filter input").setValue(false);
state.api.name.mockResolvedValue({ data: { ...revisions[0], change_summary: "复核版" } });
await page.findAll(".history-row button").find(button => button.text() === "命名")!.trigger("click");
await flushPromises();
expect(state.api.name).toHaveBeenCalledWith("ledger-a", "r2", { change_summary: "复核版" });
expect(page.get(".history-description").text()).toContain("复核版");
});
it("preserves ledger no-delete and manager-only restore while using the current generation", async () => {
const editor = render({ can_manage: false, can_export: false }); await flushPromises();
expect(editor.text()).toContain("命名");
expect(editor.text()).not.toContain("恢复此版本"); expect(editor.text()).not.toContain("另存为");
editor.unmount();
const manager = render(); await flushPromises();
expect(manager.text()).not.toContain("删除版本");
const restored = { ...revisions[0], id: "r3", revision_no: 3, source: "RESTORE" };
state.api.restore.mockResolvedValue({ data: restored });
state.api.revisions.mockResolvedValue({ data: [restored, ...revisions] });
state.api.file.mockResolvedValue({ data: { ...file, generation: 8, current_revision_id: "r3" } });
manager.findComponent({ name: "ElDropdown" }).vm.$emit("command", "restore");
// Emit the same command as the real row menu with its bound revision.
await flushPromises();
expect(state.api.restore).toHaveBeenCalledWith(expect.objectContaining({ generation: 7 }), "r1");
expect(manager.findAll(".history-row")).toHaveLength(3);
expect(manager.get(".history-row.is-current").text()).toContain("历史恢复");
});
it("opens a global read-only preview and saves a historical XLSX without project context", async () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
const page = render(); await flushPromises();
await page.findAll(".history-row button").find(button => button.text() === "预览")!.trigger("click");
expect(state.resolve).toHaveBeenCalledWith({ name: "LedgerRevisionPreview", params: { fileId: "ledger-a", id: "r2" } });
expect(open).toHaveBeenCalledWith("/ledger-preview", "_blank", "noopener,noreferrer");
const destination = { kind: "web-download" };
vi.mocked(prepareSaveFile).mockResolvedValue(destination as any);
const blob = new Blob(["historical bytes"]);
vi.mocked(downloadLedgerRevision).mockResolvedValue({ data: blob } as any);
await page.findAll(".history-row button").find(button => button.text() === "另存为")!.trigger("click");
await flushPromises();
expect(downloadLedgerRevision).toHaveBeenCalledWith("ledger-a", "r2");
expect(saveFileWithFeedback).toHaveBeenCalledWith(expect.objectContaining({ data: blob, suggestedName: expect.stringMatching(/台账\[.*\]\.xlsx$/) }), expect.anything(), destination);
});
});
@@ -1,553 +0,0 @@
<template>
<el-dialog
v-model="historyDialogVisible"
width="840px"
class="collaboration-action-dialog history-dialog"
modal-class="history-dialog-overlay"
destroy-on-close
append-to-body
@closed="closeHistory"
>
<template #header>
<div v-if="historyFile" class="history-dialog__title">
<span class="history-file-badge" :class="`is-${historyFile.file_type}`">{{ fileTypeLabel(historyFile.file_type) }}</span>
<div>
<strong>{{ historyFile.title }}</strong>
<div class="history-dialog__meta">
<span>创建者:{{ historyFile.owner_name || "—" }}</span>
<span :title="historyFile.id">文件 ID:{{ historyFile.id.slice(0, 8) }}…</span>
<span>文件大小:{{ fileSizeLabel(historyFile.current_revision_file_size) }}</span>
</div>
</div>
</div>
</template>
<div class="history-toolbar">
<label class="history-described-filter">
<span>仅显示已命名版本</span>
<el-switch v-model="historyOnlyDescribed" />
</label>
<span class="history-toolbar__spacer" />
<el-button v-if="target.scope === 'ledger' && canRestore" :disabled="historyLoading || historyActionKey !== null || restoringRevisionId !== null" @click="importNewRevision">导入新版本</el-button>
<el-select v-model="historySourceFilter" class="history-source-filter" aria-label="版本来源筛选">
<el-option label="全部来源" value="ALL" />
<el-option v-for="option in historySourceOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-tooltip content="刷新历史版本" placement="top">
<el-button circle :loading="historyLoading" aria-label="刷新历史版本" @click="refreshHistory()">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
<div v-loading="historyLoading" class="history-panel">
<div class="history-list-header">
<span>时间</span>
<span>更新者</span>
<span>大小</span>
<span>版本来源</span>
<span>版本名称</span>
<span>操作</span>
</div>
<div v-if="historyRevisionGroups.length" class="history-groups">
<section v-for="group in historyRevisionGroups" :key="group.key" class="history-day-group">
<h3>{{ group.label }}</h3>
<div v-for="row in group.items" :key="row.id" class="history-row" :class="{ 'is-current': row.id === historyFile?.current_revision_id }">
<span class="history-time">{{ revisionTimeLabel(row.created_at) }}</span>
<span class="history-author">
<el-avatar :size="26" :src="row.created_by_avatar_url || undefined">{{ revisionAuthorInitial(row) }}</el-avatar>
<span>{{ revisionAuthorName(row) }}</span>
</span>
<span>{{ formatFileSize(row.file_size) }}</span>
<span class="history-source">{{ sourceLabel(row.source) }}</span>
<span class="history-description">
<span>{{ row.change_summary || "未命名" }}</span>
<el-tag v-if="row.id === historyFile?.current_revision_id" size="small" type="info" effect="plain">当前版本</el-tag>
</span>
<span class="history-row__action">
<el-button
v-if="historyFile?.can_edit"
link
type="primary"
:loading="historyActionKey === `${row.id}:name`"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
@click="nameRevision(row)"
>命名</el-button>
<el-button
link
type="primary"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
@click="previewRevision(row)"
>预览</el-button>
<el-button
v-if="canSaveCopy"
link
type="primary"
:loading="historyActionKey === `${row.id}:copy`"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
@click="saveRevisionAs(row)"
>另存为</el-button>
<el-dropdown
v-if="canRestore && row.id !== historyFile?.current_revision_id"
trigger="click"
@command="handleRevisionMoreAction($event, row)"
>
<el-button
link
class="history-row__more"
aria-label="更多版本操作"
:loading="restoringRevisionId === row.id"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
>•••</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="restore">恢复此版本</el-dropdown-item>
<el-dropdown-item v-if="target.scope === 'project' && historyFile?.can_manage" command="delete" divided>删除版本</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</div>
</section>
</div>
<el-empty v-else description="没有符合条件的历史版本" :image-size="72" />
</div>
</el-dialog>
<CollaborationSaveAsDialog
v-if="target.scope === 'project'"
v-model="revisionSaveAsDialogVisible"
:study-id="target.scope === 'project' ? target.studyId : ''"
:initial-title="revisionSaveAsTitle"
:initial-folder-id="revisionSaveAsFolderId"
:saving="revisionSaveAsSaving"
:can-create-folder="canManageFolders"
@confirm="submitRevisionSaveAs"
/>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, onDeactivated, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Refresh } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { formatFileSize } from "../attachments/attachmentUtils";
import { copyCollaborationRevision, deleteCollaborationRevision } from "../../api/collaboration";
import { downloadLedgerRevision, importLedger } from "../../api/ledgers";
import { createCollaborationHistoryApi, type CollaborationHistoryFile } from "../../api/collaborationHistory";
import type { CollaborationAccessTarget } from "../../api/collaborationAccess";
import type { CollaborationRevision, CollaborationFileType } from "../../types/collaboration";
import { prepareSaveFile, pickFiles } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import CollaborationSaveAsDialog from "./CollaborationSaveAsDialog.vue";
const props = withDefaults(defineProps<{ file: CollaborationHistoryFile; target: CollaborationAccessTarget; canCreate?: boolean; canManageFolders?: boolean }>(), { canCreate: false, canManageFolders: false });
const emit = defineEmits<{ close: []; changed: []; updated: [file: CollaborationHistoryFile] }>();
const auth = useAuthStore();
const router = useRouter();
const api = createCollaborationHistoryApi(props.target);
const canRestore = computed(() => props.target.scope === "ledger"
? Boolean(historyFile.value?.can_manage && historyFile.value.status === "ACTIVE") : Boolean(historyFile.value?.can_edit));
const canSaveCopy = computed(() => Boolean(historyFile.value?.can_export && (props.target.scope === "ledger" || props.canCreate)));
const fileTypeLabel = (value: CollaborationFileType) => ({ word: "W", cell: "X", slide: "P" })[value];
const fileSizeLabel = (value?: number | null) => value == null ? "—" : formatFileSize(value);
const historyDialogVisible = ref(false);
const historyLoading = ref(false);
const restoringRevisionId = ref<string | null>(null);
const historyActionKey = ref<string | null>(null);
const historyOnlyDescribed = ref(false);
const historySourceFilter = ref("ALL");
const historyFile = ref<CollaborationHistoryFile | null>(null);
const revisions = ref<CollaborationRevision[]>([]);
const revisionSaveAsDialogVisible = ref(false);
const revisionSaveAsTarget = ref<CollaborationRevision | null>(null);
const revisionSaveAsFolderId = ref<string | null>(null);
const revisionSaveAsTitle = ref("");
const revisionSaveAsSaving = ref(false);
const historyRefreshTimers: number[] = [];
let historyRequestSequence = 0;
const historySourceOptions = computed(() =>
[...new Set(revisions.value.map((item) => item.source))]
.map((value) => ({ value, label: sourceLabel(value) }))
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN")),
);
const historyFilteredRevisions = computed(() => revisions.value.filter((item) =>
(historySourceFilter.value === "ALL" || item.source === historySourceFilter.value) &&
(!historyOnlyDescribed.value || Boolean(item.change_summary?.trim())),
));
const revisionDateKey = (value: string) => {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
};
const revisionDateLabel = (value: string) => {
const date = new Date(value);
const weekday = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"][date.getDay()];
return `${revisionDateKey(value)} ${weekday}`;
};
const revisionTimeLabel = (value: string) => {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const historyRevisionGroups = computed(() => {
const groups = new Map<string, CollaborationRevision[]>();
for (const revision of historyFilteredRevisions.value) {
const key = revisionDateKey(revision.created_at);
const items = groups.get(key) || [];
items.push(revision);
groups.set(key, items);
}
return [...groups.entries()].map(([key, items]) => ({
key,
label: revisionDateLabel(items[0].created_at),
items,
}));
});
const revisionAuthorName = (revision: CollaborationRevision) => {
if (revision.created_by_name) return revision.created_by_name;
if (revision.created_by && revision.created_by === auth.user?.id) {
return auth.user.full_name || auth.user.email || "当前用户";
}
return revision.created_by ? (props.target.scope === "ledger" ? "系统账号" : "项目成员") : revision.source.startsWith("SHARE_") ? "匿名协作者" : "系统";
};
const revisionAuthorInitial = (revision: CollaborationRevision) =>
revisionAuthorName(revision).trim().slice(0, 1).toUpperCase() || "?";
function sourceLabel(source: string) {
return ({
CREATE: "新建",
IMPORT: "导入",
FORCE_SAVE: "手动保存",
SESSION_CLOSE: "自动保存",
RESTORE: "历史恢复",
COPY: "复制创建",
SHARE_FORCE_SAVE: "链接协作保存",
SHARE_SESSION_CLOSE: "链接协作关闭",
SERVER_RECOVERY: "服务器备份恢复",
} as Record<string, string>)[source] || source;
}
const clearHistoryRefreshTimers = () => {
while (historyRefreshTimers.length) window.clearTimeout(historyRefreshTimers.pop());
};
const scheduleHistoryRefresh = () => {
clearHistoryRefreshTimers();
// ONLYOFFICE 的会话关闭回调可能晚于用户返回文件库;弹窗打开后继续
// 追踪最终自动保存版本,避免用户必须手动点击刷新。
for (const delay of [2_500, 12_000]) {
historyRefreshTimers.push(window.setTimeout(() => {
if (historyDialogVisible.value) void refreshHistory({ silent: true });
}, delay));
}
};
const refreshHistory = async (options: { silent?: boolean } = {}) => {
if (!historyFile.value) return;
const fileId = historyFile.value.id;
const requestSequence = historyRequestSequence + 1;
historyRequestSequence = requestSequence;
historyLoading.value = true;
try {
const [revisionResponse, fileResponse] = await Promise.all([
api.revisions(fileId),
api.file(fileId),
]);
if (
requestSequence !== historyRequestSequence ||
!historyDialogVisible.value ||
historyFile.value?.id !== fileId
) return;
revisions.value = revisionResponse.data;
historyFile.value = fileResponse.data;
emit("updated", fileResponse.data);
} catch (error) {
if ([403, 404].includes(Number((error as { response?: { status?: number } })?.response?.status))) {
closeHistory();
}
if (requestSequence === historyRequestSequence && !options.silent) {
ElMessage.error(await getApiErrorMessage(error, "修订历史加载失败"));
}
} finally {
if (requestSequence === historyRequestSequence) historyLoading.value = false;
}
};
const openHistory = async (item: CollaborationHistoryFile) => {
clearHistoryRefreshTimers();
historyFile.value = item;
revisions.value = [];
historyOnlyDescribed.value = false;
historySourceFilter.value = "ALL";
historyDialogVisible.value = true;
scheduleHistoryRefresh();
await refreshHistory();
};
const closeHistory = () => {
clearHistoryRefreshTimers();
historyRequestSequence += 1;
historyLoading.value = false;
historyFile.value = null;
revisions.value = [];
emit("close");
};
const revisionCopyTitle = (revision: CollaborationRevision) => {
if (!historyFile.value) return `历史版本[${revisionSaveAsTimeLabel(revision.created_at)}]`;
const suffix = `.${historyFile.value.extension}`;
const title = historyFile.value.title;
const stem = title.toLowerCase().endsWith(suffix) ? title.slice(0, -suffix.length) : title;
return `${stem}[${revisionSaveAsTimeLabel(revision.created_at)}]${suffix}`;
};
const revisionSaveAsTimeLabel = (value: string) => {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}︰${pad(date.getMinutes())}`;
};
const nameRevision = async (revision: CollaborationRevision) => {
if (!historyFile.value || historyActionKey.value) return;
const fileId = historyFile.value.id;
try {
const { value } = await ElMessageBox.prompt("请输入版本名称", "命名版本", {
inputValue: revision.change_summary || "",
inputValidator: (input) => Boolean(String(input || "").trim()) || "版本名称不能为空",
confirmButtonText: "保存",
});
historyActionKey.value = `${revision.id}:name`;
const { data } = await api.name(fileId, revision.id, {
change_summary: value.trim(),
});
revisions.value = revisions.value.map((item) => item.id === revision.id ? {
...item,
...data,
created_by_name: item.created_by_name,
created_by_avatar_url: item.created_by_avatar_url,
} : item);
ElMessage.success("版本名称已保存");
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "版本命名失败"));
} finally {
historyActionKey.value = null;
}
};
const previewRevision = (revision: CollaborationRevision) => {
if (!historyFile.value || historyActionKey.value || restoringRevisionId.value) return;
const fileId = historyFile.value.id;
const previewRoute = router.resolve({
name: props.target.scope === "ledger" ? "LedgerRevisionPreview" : "OfficeCollaborationRevisionPreview",
params: { fileId, id: revision.id },
});
window.open(previewRoute.href, "_blank", "noopener,noreferrer");
};
const saveRevisionAs = (revision: CollaborationRevision) => {
if (!canSaveCopy.value) return;
if (props.target.scope === "ledger") { void saveLedgerRevisionAs(revision); return; }
if (!historyFile.value || historyActionKey.value) return;
revisionSaveAsTarget.value = revision;
revisionSaveAsFolderId.value = historyFile.value.folder_id || null;
revisionSaveAsTitle.value = revisionCopyTitle(revision);
revisionSaveAsDialogVisible.value = true;
};
const submitRevisionSaveAs = async (payload: { title: string; folderId: string | null }) => {
const revision = revisionSaveAsTarget.value;
const file = historyFile.value;
if (!revision || !file || revisionSaveAsSaving.value || props.target.scope !== "project") return;
revisionSaveAsSaving.value = true;
historyActionKey.value = `${revision.id}:copy`;
try {
const { data } = await copyCollaborationRevision(props.target.studyId, file.id, revision.id, {
title: payload.title,
folder_id: payload.folderId,
});
revisionSaveAsDialogVisible.value = false;
revisionSaveAsTarget.value = null;
emit("changed");
ElMessage.success(`已在工作区创建“${data.title}”`);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "历史版本另存为失败"));
} finally {
revisionSaveAsSaving.value = false;
historyActionKey.value = null;
}
};
const handleRevisionMoreAction = (command: string, revision: CollaborationRevision) => {
if (command === "restore") void restoreRevision(revision);
if (command === "delete") void deleteRevision(revision);
};
const deleteRevision = async (revision: CollaborationRevision) => {
if (!historyFile.value || historyActionKey.value || revision.id === historyFile.value.current_revision_id || props.target.scope !== "project" || !historyFile.value.can_manage) return;
const fileId = historyFile.value.id;
try {
await ElMessageBox.confirm(
"删除此历史版本后将不再显示且无法恢复,是否继续?",
"删除历史版本",
{ type: "warning", confirmButtonText: "删除", confirmButtonClass: "el-button--danger" },
);
historyActionKey.value = `${revision.id}:delete`;
await deleteCollaborationRevision(props.target.studyId, fileId, revision.id);
revisions.value = revisions.value.filter((item) => item.id !== revision.id);
ElMessage.success("历史版本已删除");
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "历史版本删除失败"));
} finally {
historyActionKey.value = null;
}
};
const restoreRevision = async (revision: CollaborationRevision) => {
if (!historyFile.value || restoringRevisionId.value || !canRestore.value) return;
const file = historyFile.value;
const fileId = file.id;
try {
await ElMessageBox.confirm(props.target.scope === "ledger" ? "恢复将生成新版本,已打开的台账需重新加载。请确认协作者已保存内容后继续。" : "恢复此历史版本将生成一个新版本,是否继续?", "恢复历史版本", { type: "warning" });
restoringRevisionId.value = revision.id;
const { data: restored } = await api.restore(file, revision.id);
const restoredForDisplay: CollaborationRevision = {
...restored,
created_by_name: restored.created_by_name || auth.user?.full_name || auth.user?.email || null,
created_by_avatar_url: restored.created_by_avatar_url || auth.user?.avatar_url || null,
};
if (historyFile.value?.id === fileId) {
historyFile.value = {
...historyFile.value,
current_revision_id: restoredForDisplay.id,
current_revision_no: restoredForDisplay.revision_no,
current_revision_file_size: restoredForDisplay.file_size,
current_revision_mime_type: restoredForDisplay.mime_type,
current_revision_created_at: restoredForDisplay.created_at,
};
revisions.value = [
restoredForDisplay,
...revisions.value.filter((item) => item.id !== restoredForDisplay.id),
].sort((left, right) => right.revision_no - left.revision_no);
}
const [revisionResponse, fileResponse] = await Promise.all([
api.revisions(fileId),
api.file(fileId),
]);
if (historyFile.value?.id === fileId) {
revisions.value = revisionResponse.data;
const refreshedFile = fileResponse.data;
emit("updated", refreshedFile);
if (refreshedFile) historyFile.value = refreshedFile;
}
emit("changed");
ElMessage.success("已恢复并生成新版本");
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "版本恢复失败"));
} finally {
restoringRevisionId.value = null;
}
};
const saveLedgerRevisionAs = async (revision: CollaborationRevision) => {
const file = historyFile.value;
if (!file || historyActionKey.value || restoringRevisionId.value || !canSaveCopy.value) return;
const suggestedName = revisionCopyTitle(revision);
const destinationPromise = prepareSaveFile(suggestedName);
historyActionKey.value = `${revision.id}:copy`;
try {
const destination = await destinationPromise;
if (!destination) return;
const { data } = await downloadLedgerRevision(file.id, revision.id);
await saveFileWithFeedback({ suggestedName, mimeType: revision.mime_type, data }, { title: "另存台账历史版本" }, destination);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "历史版本另存为失败"));
} finally { historyActionKey.value = null; }
};
const importNewRevision = async () => {
const file = historyFile.value;
if (!file || props.target.scope !== "ledger" || !canRestore.value || historyActionKey.value || restoringRevisionId.value) return;
historyActionKey.value = "import";
try {
const [upload] = await pickFiles({ accept: [".xlsx"], multiple: false, title: "选择台账 Excel 文件" });
if (!upload) return;
await ElMessageBox.confirm("导入将生成新版本,已打开的台账需重新加载。请确认协作者已保存内容后继续。", "导入新版本", { type: "warning" });
await importLedger(file.id, upload, file.generation);
await refreshHistory();
emit("changed");
ElMessage.success("已导入为新版本");
} catch (error) {
if (error !== "cancel" && error !== "close") ElMessage.error(await getApiErrorMessage(error, "台账导入失败"));
} finally { historyActionKey.value = null; }
};
onMounted(() => { void openHistory(props.file); });
onDeactivated(closeHistory);
onBeforeUnmount(() => { clearHistoryRefreshTimers(); historyRequestSequence += 1; });
</script>
<style scoped>
.history-dialog__title { display: flex; align-items: flex-start; gap: 12px; min-width: 0; padding-right: 44px; }
.history-dialog__title > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 5px; }
.history-dialog__title strong { overflow: hidden; color: var(--ctms-text-main); font-size: 18px; font-weight: 700; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
.history-dialog__meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; color: var(--ctms-text-secondary); font-size: 12px; line-height: 1.35; }
.history-dialog__meta span { display: inline-flex; align-items: center; white-space: nowrap; }
.history-dialog__meta span + span::before { width: 1px; height: 13px; margin: 0 13px; background: var(--ctms-border-color); content: ""; }
.history-file-badge { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex: 0 0 auto; border-radius: 6px; color: #fff; font-size: 13px; font-weight: 750; }
.history-file-badge.is-word { background: #4472c4; }
.history-file-badge.is-cell { background: #2e8b57; }
.history-file-badge.is-slide { background: #d05a35; }
.history-toolbar { display: flex; align-items: center; gap: 8px; min-height: 52px; padding: 7px 18px; border-bottom: 1px solid var(--ctms-border-color); }
.history-described-filter { display: inline-flex; align-items: center; gap: 8px; color: var(--ctms-text-main); font-size: 13px; }
.history-toolbar__spacer { flex: 1; }
.history-source-filter { width: 132px; }
.history-panel { display: flex; min-height: 0; margin: 14px 18px 18px; overflow: hidden; flex: 1; flex-direction: column; border: 1px solid var(--ctms-border-color); border-radius: 9px; background: var(--ctms-bg-card); }
.history-list-header,
.history-row { display: grid; grid-template-columns: 70px 130px 78px 96px minmax(140px, 1fr) 202px; min-width: 760px; align-items: center; column-gap: 8px; }
.history-list-header { min-height: 44px; padding: 0 18px; flex: 0 0 auto; border-bottom: 1px solid var(--ctms-border-color); background: var(--ctms-bg-muted); color: var(--ctms-text-regular); font-size: 13px; font-weight: 650; }
.history-groups { min-height: 0; max-height: none; overflow: auto; flex: 1; }
.history-day-group { min-width: 760px; padding: 0 18px; }
.history-day-group + .history-day-group { border-top: 1px solid var(--ctms-border-color); }
.history-day-group h3 { margin: 0; padding: 15px 0 5px; color: var(--ctms-text-main); font-size: 15px; font-weight: 700; line-height: 1.25; }
.history-row { min-height: 48px; padding: 0; border-bottom: 1px solid color-mix(in srgb, var(--ctms-border-color) 42%, transparent); color: var(--ctms-text-regular); font-size: 13px; transition: background-color 0.16s ease; }
.history-row:last-child { border-bottom: 0; }
.history-row:hover { background: var(--ctms-bg-muted); }
.history-row.is-current { background: color-mix(in srgb, var(--ctms-primary) 5%, var(--ctms-bg-card)); }
.history-time { padding-left: 5px; color: var(--ctms-text-main); font-variant-numeric: tabular-nums; }
.history-author { display: inline-flex; min-width: 0; align-items: center; gap: 7px; }
.history-author :deep(.el-avatar) { flex: 0 0 auto; background: var(--ctms-primary); color: #fff; font-size: 11px; font-weight: 700; }
.history-author > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-source { color: var(--ctms-text-regular); }
.history-description { display: flex; min-width: 0; align-items: center; gap: 8px; }
.history-description > span { overflow: hidden; color: var(--ctms-text-secondary); text-overflow: ellipsis; white-space: nowrap; }
.history-row__action { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 1px; opacity: 0.72; transition: opacity 0.16s ease; }
.history-row__action :deep(.el-button) { margin-left: 0; padding: 4px 5px; font-size: 12px; }
.history-row__more { letter-spacing: 1px; }
.history-row:hover .history-row__action,
.history-row:focus-within .history-row__action { opacity: 1; }
:global(.history-dialog.el-dialog) { display: flex; width: 840px; height: min(760px, 82vh); max-width: calc(100vw - 40px); max-height: calc(100vh - 64px); margin: 8vh auto 0 !important; overflow: hidden; flex-direction: column; padding: 0; border-radius: 14px; }
:global(.history-dialog.el-dialog .el-dialog__header) { margin: 0; padding: 16px 18px 14px; flex: 0 0 auto; border-bottom: 1px solid var(--ctms-border-color); }
:global(.history-dialog.el-dialog .el-dialog__headerbtn) { top: 13px; right: 13px; width: 36px; height: 36px; }
:global(.history-dialog.el-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; flex-direction: column; padding: 0; }
:global(.history-dialog-overlay.el-overlay),
:global(.history-dialog-overlay .el-overlay-dialog) { overflow: hidden; }
:global(body.is-desktop-runtime .history-dialog.el-dialog) { margin: 0 auto !important; --el-dialog-padding-primary: 0; padding: 0 !important; overflow: hidden !important; }
@media (max-width: 900px) {
.history-dialog__meta span + span::before { margin: 0 8px; }
.history-toolbar { flex-wrap: wrap; }
.history-toolbar__spacer { display: none; }
.history-source-filter { margin-left: auto; }
.history-panel { margin-right: 12px; margin-left: 12px; overflow-x: auto; }
}
</style>
@@ -1,13 +0,0 @@
<template>
<CollaborationHistoryDialog :file="ledger" :target="{ scope: 'ledger' }"
@close="emit('close')" @changed="emit('changed')" @updated="emit('updated', $event)" />
</template>
<script setup lang="ts">
import CollaborationHistoryDialog from "../collaboration/CollaborationHistoryDialog.vue";
import type { Ledger } from "../../types/ledger";
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
const { ledger } = defineProps<{ ledger: Ledger }>();
const emit = defineEmits<{ close: []; changed: []; updated: [file: CollaborationHistoryFile] }>();
</script>
@@ -1,132 +0,0 @@
<template>
<el-dialog :model-value="true" title="信息维护" width="560px" class="ledger-info-dialog"
append-to-body align-center :close-on-click-modal="false" @close="emit('close')">
<template #header="{ titleId }">
<div class="ledger-info-header">
<span class="ledger-info-header__icon" aria-hidden="true"><el-icon><EditPen /></el-icon></span>
<h2 :id="titleId">信息维护</h2>
</div>
</template>
<div v-if="error" class="ledger-error" role="alert">{{ error }} <el-button link @click="refresh">重试</el-button></div>
<el-form id="ledger-info-form" v-loading="loading" class="ledger-info-form" label-position="top"
:disabled="busy || loading || !current.can_manage" @submit.prevent="save">
<el-form-item label="台账名称" required>
<el-input v-model="form.title" maxlength="240" aria-label="台账名称" />
</el-form-item>
<el-form-item label="用途说明">
<template #label>用途说明<span class="ledger-field-optional">选填</span></template>
<el-input v-model="form.description" type="textarea" maxlength="500" :rows="3"
show-word-limit resize="vertical" aria-label="用途说明" />
</el-form-item>
<div class="ledger-info-details">
<el-form-item label="所有者" class="ledger-info-owner">
<div class="ledger-owner-identity">
<el-avatar :size="32" class="ledger-owner-avatar" aria-hidden="true">{{ ownerInitial }}</el-avatar>
<span class="ledger-owner-name" :title="current.owner_name">{{ current.owner_name || '—' }}</span>
</div>
</el-form-item>
<el-form-item label="台账状态" class="ledger-info-status">
<el-radio-group v-model="form.status" class="ledger-status-options" aria-label="台账状态">
<el-radio-button value="ACTIVE">使用中</el-radio-button>
<el-radio-button value="ARCHIVED">已归档(只读)</el-radio-button>
</el-radio-group>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button :disabled="busy" @click="emit('close')">取消</el-button>
<el-button type="primary" :loading="busy" :disabled="loading || !current.can_manage"
native-type="submit" form="ledger-info-form">保存信息</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { EditPen } from "@element-plus/icons-vue";
import { fetchLedger, updateLedger } from "../../api/ledgers";
import type { Ledger, LedgerSettings } from "../../types/ledger";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
const props = defineProps<{ ledger: Ledger }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const current = ref(props.ledger);
const ownerInitial = computed(() => current.value.owner_name?.trim().slice(0, 1).toUpperCase() || "?");
const toSettings = (ledger: Ledger): LedgerSettings => ({
title: ledger.title.replace(/\.xlsx$/i, ""), description: ledger.description, status: ledger.status,
owner_id: ledger.owner_id, allow_export: ledger.allow_export,
allow_sheet_structure_edit: ledger.allow_sheet_structure_edit, generation: ledger.generation,
});
const form = ref(toSettings(props.ledger));
const loading = ref(false);
const busy = ref(false);
const error = ref("");
const refresh = async () => {
loading.value = true;
error.value = "";
try {
current.value = (await fetchLedger(current.value.id)).data;
form.value = toSettings(current.value);
if (!current.value.can_manage) {
emit("changed");
emit("close");
}
} catch (cause) { error.value = await getApiErrorMessage(cause, "台账信息加载失败"); }
finally { loading.value = false; }
};
const save = async () => {
if (busy.value || loading.value || !current.value.can_manage) return;
if (!form.value.title.trim()) { ElMessage.warning("请输入台账名称"); return; }
busy.value = true;
try {
await ElMessageBox.confirm("此操作会使已打开的台账重新加载。请确认协作者已保存内容后继续。", "更新台账信息", {
confirmButtonText: "继续", cancelButtonText: "取消", type: "warning",
});
await updateLedger(current.value.id, form.value);
ElMessage.success("台账信息已保存");
emit("changed");
emit("close");
} catch (cause) {
if (cause !== "cancel" && cause !== "close") error.value = await getApiErrorMessage(cause, "信息保存失败,请刷新后重试");
} finally { busy.value = false; }
};
onMounted(refresh);
</script>
<style scoped>
.ledger-info-header { display: flex; align-items: center; gap: 10px; padding-right: 30px; }
.ledger-info-header__icon { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; border-radius: 9px; background: var(--ctms-primary-light); color: var(--ctms-primary); font-size: 17px; }
.ledger-info-header h2 { margin: 0; color: var(--ctms-text-main); font-size: 18px; font-weight: 700; line-height: 26px; }
.ledger-info-form :deep(.el-form-item) { margin-bottom: 18px; }
.ledger-info-form :deep(.el-form-item__label) { height: auto; margin-bottom: 7px; color: var(--ctms-text-main); font-size: 13px; font-weight: 600; line-height: 20px; }
.ledger-field-optional { margin-left: 7px; color: var(--ctms-text-secondary); font-size: 12px; font-weight: 400; }
.ledger-info-form :deep(.el-input__wrapper) { min-height: 36px; padding: 0 11px; border-radius: 8px; }
.ledger-info-form :deep(.el-input__inner) { height: 36px; color: var(--ctms-text-main); font-size: 14px; }
.ledger-info-form :deep(.el-textarea__inner) { min-height: 88px; padding: 9px 11px 23px; border-radius: 8px; background: var(--ctms-bg-card); color: var(--ctms-text-main); font-size: 13px; line-height: 20px; }
.ledger-info-form :deep(.el-input__count) { right: 11px; bottom: 5px; background: var(--ctms-bg-card); color: var(--ctms-text-disabled); font-size: 11px; line-height: 16px; }
.ledger-info-details { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: 20px; padding-top: 16px; border-top: 1px solid var(--ctms-border-color); }
.ledger-info-details :deep(.el-form-item) { min-width: 0; margin-bottom: 0; }
.ledger-owner-identity { display: flex; align-items: center; gap: 9px; min-width: 0; width: 100%; min-height: 36px; }
.ledger-owner-avatar { flex-shrink: 0; background: var(--ctms-bg-muted); color: var(--ctms-primary); font-size: 13px; font-weight: 700; }
.ledger-owner-name { overflow: hidden; color: var(--ctms-text-regular); font-size: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.ledger-status-options { display: flex; flex-wrap: nowrap; width: 100%; }
.ledger-status-options :deep(.el-radio-button) { flex: 1; }
.ledger-status-options :deep(.el-radio-button__inner) { display: flex; align-items: center; justify-content: center; width: 100%; height: 36px; padding: 0 10px; font-size: 12px; line-height: 18px; }
.ledger-status-options :deep(.el-radio-button:first-child .el-radio-button__inner) { border-radius: 8px 0 0 8px; }
.ledger-status-options :deep(.el-radio-button:last-child .el-radio-button__inner) { border-radius: 0 8px 8px 0; }
.ledger-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 16px; color: var(--ctms-danger); background: color-mix(in srgb, var(--ctms-danger) 8%, var(--ctms-bg-card)); border-radius: 8px; font-size: 13px; }
:global(.ledger-info-dialog.el-dialog) { display: flex; flex-direction: column; max-width: calc(100vw - 32px); max-height: calc(100vh - 48px); max-height: calc(100dvh - 48px); padding: 0; overflow: hidden; border: 1px solid var(--ctms-border-color); border-radius: 14px; background: var(--ctms-bg-card); }
:global(.ledger-info-dialog.el-dialog .el-dialog__header) { flex-shrink: 0; margin: 0; padding: 16px 22px; border-bottom: 1px solid var(--ctms-border-color); background: var(--ctms-bg-card); }
:global(.ledger-info-dialog.el-dialog .el-dialog__headerbtn) { top: 16px; right: 16px; width: 32px; height: 32px; }
:global(.ledger-info-dialog.el-dialog .el-dialog__body) { min-height: 0; padding: 20px 22px; overflow-y: auto; }
:global(.ledger-info-dialog.el-dialog .el-dialog__footer) { display: flex; flex-shrink: 0; align-items: center; justify-content: flex-end; gap: 10px; padding: 13px 22px; border-top: 1px solid var(--ctms-border-color); background: var(--ctms-bg-muted); }
:global(.ledger-info-dialog.el-dialog .el-dialog__footer .el-button) { height: 34px; min-width: 80px; margin-left: 0; padding: 0 16px; border-radius: 8px; font-size: 13px; }
:global(body.is-desktop-runtime .ledger-info-dialog.el-dialog) { max-height: calc(100dvh - 96px); }
@media (max-width: 540px) {
.ledger-info-details { grid-template-columns: minmax(0, 1fr); gap: 16px; }
:global(.ledger-info-dialog.el-dialog .el-dialog__header),
:global(.ledger-info-dialog.el-dialog .el-dialog__body),
:global(.ledger-info-dialog.el-dialog .el-dialog__footer) { padding-left: 16px; padding-right: 16px; }
}
</style>
@@ -1,63 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import LedgerPanel from "./LedgerPanel.vue";
import { fetchLedgers, initializeLedgers } from "../../api/ledgers";
const state = vi.hoisted(() => ({ user: { is_admin: false }, push: vi.fn() }));
vi.mock("../../api/ledgers", () => ({ fetchLedgers: vi.fn(), initializeLedgers: vi.fn() }));
vi.mock("../../store/auth", () => ({ useAuthStore: () => state }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push: state.push }) }));
vi.mock("./LedgerInfoDialog.vue", () => ({ default: { template: "<div />" } }));
vi.mock("./LedgerHistoryDialog.vue", () => ({ default: { template: "<div />" } }));
vi.mock("../collaboration/CollaborationAccessDialog.vue", () => ({ default: { template: "<div />" } }));
vi.mock("../../utils/apiErrorMessage", () => ({ getApiErrorMessage: async (_error: unknown, fallback: string) => fallback }));
const ledger = {
id: "ledger-a", title: "医学事务部合同台账明细表.xlsx", description: "合同台账", owner_name: "负责人",
status: "ACTIVE", can_edit: true, can_manage: false, current_revision_created_at: "2026-09-03T00:00:00Z",
};
const render = () => mount(LedgerPanel, { global: { stubs: {
ElButton: { template: '<button><slot /></button>' }, ElIcon: { template: '<span><slot /></span>' },
ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
} } });
describe("global ledger entries", () => {
beforeEach(() => { vi.clearAllMocks(); state.user.is_admin = false; });
it("opens the granted ledger without needing a selected project", async () => {
vi.mocked(fetchLedgers).mockResolvedValue({ data: [ledger] } as any);
const wrapper = render();
await flushPromises();
expect(wrapper.text()).toContain("医学事务部合同台账明细表");
expect(wrapper.text()).not.toContain("台账管理");
await wrapper.get(".ledger-open").trigger("click");
expect(state.push).toHaveBeenCalledWith({ name: "LedgerWorkspace", params: { fileId: "ledger-a" } });
wrapper.unmount();
});
it("offers initialization only to administrators and shows an authorization empty state", async () => {
vi.mocked(fetchLedgers).mockResolvedValue({ data: [] } as any);
const viewer = render();
await flushPromises();
expect(viewer.text()).toContain("暂未获得台账访问权限");
expect(viewer.text()).not.toContain("初始化两本台账");
viewer.unmount();
state.user.is_admin = true;
vi.mocked(initializeLedgers).mockResolvedValue({ data: [ledger] } as any);
const admin = render();
await flushPromises();
await admin.get("button").trigger("click");
await flushPromises();
expect(initializeLedgers).toHaveBeenCalledOnce();
expect(admin.findAll(".ledger-card")).toHaveLength(1);
admin.unmount();
});
it("shows a recoverable error instead of misreporting failed authorization loading as no ledgers", async () => {
vi.mocked(fetchLedgers).mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [ledger] } as any);
const wrapper = render();
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain("台账加载失败");
await wrapper.get("button").trigger("click");
await flushPromises();
expect(wrapper.findAll(".ledger-card")).toHaveLength(1);
wrapper.unmount();
});
});
@@ -1,128 +0,0 @@
<template>
<section class="ledger-panel" aria-labelledby="ledger-heading">
<header class="ledger-heading">
<span class="ledger-eyebrow">Collaborative Ledgers</span>
<div class="entry-title-row">
<h2 id="ledger-heading">协作台账</h2>
<EntryCountBadge v-if="ledgers.length">{{ ledgers.length }} 本可访问台账</EntryCountBadge>
</div>
</header>
<div v-if="loading" class="ledger-state" role="status">正在加载台账…</div>
<div v-else-if="error" class="ledger-state" role="alert">
<p>{{ error }}</p><el-button @click="load">重新加载</el-button>
</div>
<div v-else-if="!ledgers.length" class="ledger-state">
<p>{{ isAdmin ? '启用两本医学事务部台账,开始在线协作。' : '暂未获得台账访问权限,请联系管理员授权。' }}</p>
<el-button v-if="isAdmin" type="primary" :loading="initializing" @click="initialize">初始化两本台账</el-button>
</div>
<div v-else class="ledger-grid">
<article v-for="ledger in ledgers" :key="ledger.id" class="ledger-card">
<div class="ledger-card-top">
<div class="ledger-title-stack">
<h3 :title="displayTitle(ledger.title)">{{ displayTitle(ledger.title) }}</h3>
<p v-if="ledger.description?.trim()" class="ledger-description" :title="ledger.description">{{ ledger.description }}</p>
</div>
<span class="ledger-access" :class="{ 'is-readonly': !ledger.can_edit }">
{{ ledger.status === 'ARCHIVED' ? '已归档 · 只读' : ledger.can_edit ? '可编辑' : '只读' }}
</span>
</div>
<dl><div><dt>负责人</dt><dd>{{ ledger.owner_name }}</dd></div>
<div><dt>最近保存</dt><dd>{{ formatTime(ledger.current_revision_created_at) }}</dd></div></dl>
<footer>
<button class="ledger-open" type="button" @click="open(ledger)">打开台账 <el-icon><ArrowRight /></el-icon></button>
<el-dropdown v-if="ledger.can_manage" trigger="click" @command="handleManagementCommand($event, ledger)">
<el-button link>台账管理 <el-icon><ArrowDown /></el-icon></el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item command="info">信息维护</el-dropdown-item>
<el-dropdown-item command="access">访问与权限</el-dropdown-item>
<el-dropdown-item command="history">历史版本</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
<el-button v-else link @click="openManagement('history', ledger)">历史版本</el-button>
</footer>
</article>
</div>
<LedgerInfoDialog v-if="management?.section === 'info'" :ledger="management.ledger" @close="management = null" @changed="load" />
<CollaborationAccessDialog v-if="management?.section === 'access'" :file="management.ledger" :target="{ scope: 'ledger' }"
@close="management = null" @changed="load" />
<LedgerHistoryDialog v-if="management?.section === 'history'" :ledger="management.ledger" @close="management = null" @changed="load" @updated="updateLedgerSummary" />
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ArrowDown, ArrowRight } from "@element-plus/icons-vue";
import { fetchLedgers, initializeLedgers } from "../../api/ledgers";
import { useAuthStore } from "../../store/auth";
import { isSystemAdmin } from "../../utils/roles";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import type { Ledger } from "../../types/ledger";
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
import LedgerInfoDialog from "./LedgerInfoDialog.vue";
import LedgerHistoryDialog from "./LedgerHistoryDialog.vue";
import CollaborationAccessDialog from "../collaboration/CollaborationAccessDialog.vue";
import EntryCountBadge from "../EntryCountBadge.vue";
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const ledgers = ref<Ledger[]>([]);
const updateLedgerSummary = (file: CollaborationHistoryFile) => {
ledgers.value = ledgers.value.map((ledger) => ledger.id === file.id ? { ...ledger, ...file } as Ledger : ledger);
};
const loading = ref(true);
const initializing = ref(false);
const error = ref("");
type ManagementSection = "info" | "access" | "history";
const management = ref<{ section: ManagementSection; ledger: Ledger } | null>(null);
const openManagement = (section: ManagementSection, ledger: Ledger) => { management.value = { section, ledger }; };
const handleManagementCommand = (command: string, ledger: Ledger) => {
if (command === "info" || command === "access" || command === "history") openManagement(command, ledger);
};
const displayTitle = (title: string) => title.replace(/\.xlsx$/i, "");
const formatTime = (value: string | null) => value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "尚未保存";
const open = (ledger: Ledger) => router.push({ name: "LedgerWorkspace", params: { fileId: ledger.id } });
const load = async () => {
loading.value = true;
error.value = "";
try { ledgers.value = (await fetchLedgers()).data; }
catch (cause) { ledgers.value = []; error.value = await getApiErrorMessage(cause, "台账加载失败"); }
finally { loading.value = false; }
};
const initialize = async () => {
if (initializing.value) return;
initializing.value = true;
try { ledgers.value = (await initializeLedgers()).data; }
catch (cause) { error.value = await getApiErrorMessage(cause, "台账初始化失败"); }
finally { initializing.value = false; }
};
onMounted(load);
</script>
<style scoped>
.ledger-panel { margin-top: 44px; padding-bottom: 28px; }
.ledger-heading { margin-bottom: 26px; }
.ledger-eyebrow { color: #387cf4; font-size: 11px; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; }
.ledger-heading h2 { margin: 0; color: #162033; font-size: 24px; }
.ledger-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.ledger-card { display: flex; flex-direction: column; min-width: 0; background: #fff; border: 1px solid rgba(226, 232, 240, 0.85); border-radius: 12px; box-shadow: 0 4px 15px rgba(15, 23, 42, 0.01), 0 1px 2px rgba(15, 23, 42, 0.02); }
.ledger-card-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; margin-bottom: 16px; }
.ledger-title-stack { display: flex; flex-direction: column; min-width: 0; }
.ledger-access { display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 8px; border-radius: 4px; color: #218266; background: #edfaf5; font-size: 10px; font-weight: 700; white-space: nowrap; }
.ledger-access.is-readonly { color: #667289; background: #f1f4f8; }
.ledger-card h3 { margin: 0; font-size: 14.5px; line-height: 18px; font-weight: 800; color: #0f172a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-description { color: #64748b; font-size: 10.5px; line-height: 13px; font-weight: 700; letter-spacing: 0.01em; margin: 1px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-card dl { display: flex; flex-direction: column; gap: 5px; font-size: 11.5px; margin: 0 0 14px; }
.ledger-card dl > div { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.ledger-card dt { color: #64748b; font-weight: 600; flex-shrink: 0; }
.ledger-card dd { margin: 0; color: #0f172a; font-weight: 550; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-card footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid rgba(241, 245, 249, 0.85); margin-top: auto; padding-top: 10px; }
.ledger-open { display: flex; align-items: center; gap: 10px; border: 0; padding: 0; background: transparent; color: #1e3a8a; font: inherit; font-size: 11px; font-weight: 800; cursor: pointer; }
.ledger-open .el-icon { width: 20px; height: 20px; border-radius: 50%; background: rgba(59, 130, 246, 0.06); color: #2563eb; }
.ledger-card footer .el-button { height: 20px; padding: 0; font-size: 11px; }
.ledger-open:focus-visible { outline: 2px solid #387cf4; outline-offset: 4px; }
.ledger-state { padding: 28px; background: #fff; border: 1px dashed #d6e1ef; border-radius: 14px; color: #738199; font-size: 14px; }
.ledger-state p { margin: 0 0 14px; }
:global(.desktop-entry .ledger-card) { border-radius: 8px; }
</style>
-1
View File
@@ -3,7 +3,6 @@
interface ImportMetaEnv {
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
readonly VITE_BUILD_COMMIT?: string;
readonly VITE_DESKTOP_SERVER_URL?: string;
}
interface ImportMeta {
+2 -5
View File
@@ -14,10 +14,7 @@ describe("admin project route permissions", () => {
expect(source).toContain('name: "OfficeVersionPreview"');
expect(source).toContain('path: "office-preview/collaboration/:fileId/revision/:id"');
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source).toContain('path: "/ledgers/:fileId/revisions/:id/preview"');
expect(source).toContain('name: "LedgerRevisionPreview"');
expect(source).toContain('meta: { title: "台账历史版本预览", ledgerWorkspace: true }');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(4);
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(3);
expect(source.match(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
expect(source.match(/transientWorkspaceTask: true/g)?.length).toBeGreaterThanOrEqual(2);
});
@@ -142,7 +139,7 @@ describe("admin project route permissions", () => {
expect(source).toContain("const canAccessProjectManagement = studyStore.currentStudyRole === \"PM\"");
expect(source).toContain("if (!canAccessProjectManagement)");
expect(source).toContain('next({ path: DESKTOP_PROJECT_ENTRY_PATH });');
expect(source).toContain("if (token && (isWorkbenchEntryPath(to.path) || to.meta.ledgerWorkspace) && studyStore.currentStudy)");
expect(source).toContain("if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy)");
expect(source).not.toContain("if (token && !studyStore.currentStudy && !isDesktopRuntime)");
expect(source).not.toContain("ensureDefaultActiveStudy();");
expect(source).not.toContain("ensureDefaultStudy();");
+1 -13
View File
@@ -121,18 +121,6 @@ const routes: RouteRecordRaw[] = [
component: WebWorkbenchEntry,
meta: { title: "工作台入口" },
},
{
path: "/ledgers/:fileId",
name: "LedgerWorkspace",
component: CollaborationWorkspace,
meta: { title: "协作台账", ledgerWorkspace: true },
},
{
path: "/ledgers/:fileId/revisions/:id/preview",
name: "LedgerRevisionPreview",
component: OfficePreviewWorkspace,
meta: { title: "台账历史版本预览", ledgerWorkspace: true },
},
{
path: DESKTOP_PROJECT_ENTRY_PATH,
name: "DesktopProjectEntry",
@@ -653,7 +641,7 @@ router.beforeEach(async (to, _from, next) => {
studyStore.rememberCurrentStudyForUser(auth.user.email);
}
if (token && (isWorkbenchEntryPath(to.path) || to.meta.ledgerWorkspace) && studyStore.currentStudy) {
if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy) {
studyStore.clearCurrentStudy();
}
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
DESKTOP_SERVER_URL_KEY,
getDefaultDesktopServerUrl,
getDesktopServerUrl,
normalizeDesktopServerUrl,
setDesktopServerUrl,
@@ -32,7 +31,6 @@ const createMemoryStorage = (): Storage => {
};
beforeEach(() => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "");
Object.defineProperty(window, "localStorage", {
value: createMemoryStorage(),
configurable: true,
@@ -42,7 +40,6 @@ beforeEach(() => {
afterEach(() => {
window.localStorage.clear();
setTauriRuntime(false);
vi.unstubAllEnvs();
});
describe("desktop server config", () => {
@@ -73,23 +70,6 @@ describe("desktop server config", () => {
expect(listener).toHaveBeenCalledOnce();
});
it("uses the build-time default while allowing a persisted manual override", () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
expect(getDefaultDesktopServerUrl()).toBe("https://default.ctms.example.com/");
expect(getDesktopServerUrl()).toBe("https://default.ctms.example.com/");
setDesktopServerUrl("https://manual.ctms.example.com");
expect(getDesktopServerUrl()).toBe("https://manual.ctms.example.com/");
});
it("ignores an invalid build-time default", () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "http://public.ctms.example.com");
expect(getDefaultDesktopServerUrl()).toBeNull();
expect(getDesktopServerUrl()).toBeNull();
});
it("requires a server URL only inside the Tauri runtime", () => {
expect(shouldRequireDesktopServerUrl()).toBe(false);
setTauriRuntime(true);
@@ -97,11 +77,4 @@ describe("desktop server config", () => {
setDesktopServerUrl("https://ctms.example.com");
expect(shouldRequireDesktopServerUrl()).toBe(false);
});
it("does not require first-run configuration when a valid build-time default exists", () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
setTauriRuntime(true);
expect(shouldRequireDesktopServerUrl()).toBe(false);
});
});
+2 -11
View File
@@ -51,22 +51,13 @@ export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValida
return { ok: true, url: `${parsed.origin}/` };
};
export const getDefaultDesktopServerUrl = (): string | null => {
const configured = import.meta.env.VITE_DESKTOP_SERVER_URL?.trim();
if (!configured) return null;
const result = normalizeDesktopServerUrl(configured);
return result.ok ? result.url : null;
};
const getStoredDesktopServerUrl = (): string | null => {
export const getDesktopServerUrl = (): string | null => {
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
if (!stored) return null;
const result = normalizeDesktopServerUrl(stored);
return result.ok ? result.url : null;
};
export const getDesktopServerUrl = (): string | null => getStoredDesktopServerUrl() || getDefaultDesktopServerUrl();
export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl());
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
@@ -83,7 +74,7 @@ export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationRe
export const clearDesktopServerUrl = (): void => {
const previous = getDesktopServerUrl();
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
emitServerUrlChanged(previous, getDesktopServerUrl());
emitServerUrlChanged(previous, null);
};
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
-1
View File
@@ -2,7 +2,6 @@ export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./c
export {
clearDesktopServerUrl,
DESKTOP_SERVER_URL_CHANGED_EVENT,
getDefaultDesktopServerUrl,
getDesktopServerUrl,
hasDesktopServerUrl,
normalizeDesktopServerUrl,
@@ -41,7 +41,6 @@ const createStorage = (): Storage => {
describe("secure session storage", () => {
beforeEach(() => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "");
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
resetSecureSessionStorageForTests();
@@ -54,7 +53,6 @@ describe("secure session storage", () => {
});
afterEach(() => {
vi.unstubAllEnvs();
vi.useRealTimers();
resetSecureSessionStorageForTests();
localStorage.clear();
@@ -160,25 +158,6 @@ describe("secure session storage", () => {
expect(invokeMock).not.toHaveBeenCalled();
});
it("restores credentials for the build default when no desktop server override is stored", async () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
localStorage.removeItem(DESKTOP_SERVER_URL_KEY);
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockResolvedValue(JSON.stringify({
version: 1,
token,
storedAt: Date.now(),
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
}));
await initializeSecureSessionStorage();
expect(getSessionToken()).toBe(token);
expect(invokeMock).toHaveBeenCalledExactlyOnceWith("credential_get", {
serverOrigin: "https://default.ctms.example.com/",
});
});
it("clears the previous server credential after a desktop server switch", async () => {
const previousServerOrigin = "https://old.ctms.example.com/";
const nextServerOrigin = "https://new.ctms.example.com/";
-45
View File
@@ -1,45 +0,0 @@
/* 工作台的项目和台账共用尺寸,说明文字是否存在不影响卡片高度。 */
:is(.web-entry, .desktop-entry) {
--entry-card-height: 172px;
}
:is(.web-entry, .desktop-entry) .entry-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 4px;
}
/* 项目显示第三行资料时,所有卡片一起增加一行的空间。 */
:is(.web-entry, .desktop-entry):has(.card-meta-list > .meta-item:nth-child(3)) {
--entry-card-height: 193px;
}
:is(.web-entry, .desktop-entry) :is(.modern-project-card, .ledger-card, .skeleton-project-card) {
box-sizing: border-box;
height: var(--entry-card-height);
min-height: var(--entry-card-height);
padding: 20px;
font-family: Arial, sans-serif;
}
:is(.web-entry, .desktop-entry) :is(.card-top-info, .ledger-card-top) {
min-height: 32px;
flex-shrink: 0;
}
:is(.web-entry, .desktop-entry) .project-title-stack small {
overflow: hidden;
line-height: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
:is(.web-entry, .desktop-entry) .project-title-stack strong {
line-height: 18px;
}
:is(.web-entry, .desktop-entry) :is(.card-meta-list, .ledger-card dl) {
line-height: 16px;
}
-48
View File
@@ -1,48 +0,0 @@
export type LedgerRole = "VIEWER" | "EDITOR" | "MANAGER";
export interface Ledger {
id: string;
title: string;
description: string;
status: "ACTIVE" | "ARCHIVED";
owner_id: string;
owner_name: string;
generation: number;
updated_at: string;
current_revision_id: string | null;
current_revision_no: number | null;
current_revision_file_size: number | null;
current_revision_mime_type: string | null;
current_revision_created_at: string | null;
role: LedgerRole;
can_edit: boolean;
can_manage: boolean;
can_export: boolean;
allow_export: boolean;
allow_edit_request: boolean;
allow_sheet_structure_edit: boolean;
file_type: "cell";
extension: "xlsx";
can_request_edit: boolean;
edit_request_status?: "PENDING" | "APPROVED" | "REJECTED" | null;
can_transfer_ownership: boolean;
}
export type LedgerSettings = Pick<Ledger,
"title" | "description" | "status" | "owner_id" | "allow_export" | "allow_sheet_structure_edit" | "generation"
>;
export interface LedgerCandidate {
user_id: string;
full_name: string;
email: string;
role_in_study: string;
can_be_editor: boolean;
can_be_manager: boolean;
}
export interface LedgerMember extends Pick<LedgerCandidate, "user_id" | "full_name" | "email"> {
role: LedgerRole;
is_active: boolean;
protected_label?: string | null;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision" | "ledger_revision";
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision";
export interface OnlyOfficePreviewConfig {
resource_type: OnlyOfficeResourceType;
+45 -9
View File
@@ -53,10 +53,14 @@
<div class="project-header">
<div class="header-title-block">
<span class="section-eyebrow">Project Workspace</span>
<div class="entry-title-row">
<h2>选择目标项目</h2>
<EntryCountBadge v-if="!loading && projects.length">{{ projects.length }} 个可进入项目</EntryCountBadge>
</div>
<h2>选择目标项目</h2>
</div>
<div class="header-actions-block">
<span v-if="!loading && projects.length" class="project-pill-badge">
<span class="badge-dot"></span>
<span>{{ projects.length }} 个可进入项目</span>
</span>
</div>
</div>
@@ -126,7 +130,6 @@
<h3>未检测到可用项目</h3>
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
</div>
<LedgerPanel />
</main>
<!-- 科技感转场遮罩 -->
@@ -154,9 +157,6 @@ import type { Study } from "../types/api";
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
import { isSystemAdmin } from "../utils/roles";
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
import LedgerPanel from "../components/ledgers/LedgerPanel.vue";
import EntryCountBadge from "../components/EntryCountBadge.vue";
import "../styles/entry-cards.css";
const auth = useAuthStore();
const studyStore = useStudyStore();
@@ -508,7 +508,7 @@ onMounted(() => {
}
.header-title-block h2 {
margin: 0;
margin: 2px 0 0;
color: #0f172a;
font-size: 22px;
font-weight: 800;
@@ -543,6 +543,33 @@ onMounted(() => {
content: "";
}
.header-actions-block {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.project-pill-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
font-size: 11.5px;
font-weight: 700;
}
.badge-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #3b82f6;
}
.btn-refresh-work {
min-width: 76px;
height: 28px;
@@ -565,6 +592,8 @@ onMounted(() => {
position: relative;
display: flex;
flex-direction: column;
min-height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
background: #ffffff;
@@ -769,6 +798,8 @@ onMounted(() => {
/* 骨架屏 */
.skeleton-project-card {
height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
background: #ffffff;
@@ -899,6 +930,11 @@ onMounted(() => {
color: #e2e8f0;
}
:global([data-ctms-theme="dark"] .project-pill-badge) {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
:global([data-ctms-theme="dark"] .modern-project-card) {
background: rgba(15, 23, 42, 0.55);
border-color: rgba(51, 65, 85, 0.5);
+4 -14
View File
@@ -1,5 +1,5 @@
<template>
<section class="office-preview-page" :class="{ 'is-ledger-preview': resourceType === 'ledger_revision' }">
<section class="office-preview-page">
<header class="office-preview-page__header">
<button
class="office-preview-page__back"
@@ -44,10 +44,9 @@ import {
fetchCollaborationRevisionOnlyOfficeConfig,
fetchVersionOnlyOfficeConfig,
} from "../api/onlyoffice";
import { fetchLedgerRevisionPreview } from "../api/ledgers";
import { useStudyStore } from "../store/study";
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH, isTauriRuntime } from "../runtime";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
import { getApiErrorMessage } from "../utils/apiErrorMessage";
import { workspaceTaskControllerKey } from "../components/layout/workspaceTaskController";
@@ -68,7 +67,6 @@ let mounted = false;
const resourceType = computed<OnlyOfficeResourceType>(() => {
if (route.name === "OfficeAttachmentPreview") return "attachment";
if (route.name === "LedgerRevisionPreview") return "ledger_revision";
if (route.name === "OfficeCollaborationRevisionPreview") return "collaboration_revision";
return "version";
});
@@ -113,8 +111,6 @@ const loadConfig = async () => {
try {
const response = resourceType.value === "attachment"
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
: resourceType.value === "ledger_revision"
? await fetchLedgerRevisionPreview(collaborationFileId.value, resourceId.value)
: resourceType.value === "collaboration_revision"
? await fetchCollaborationRevisionOnlyOfficeConfig(
study.currentStudy?.id || "",
@@ -128,7 +124,7 @@ const loadConfig = async () => {
}
previewConfig.value = response.data;
viewerSequence.value += 1;
if (resourceType.value !== "ledger_revision") study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
} catch (error) {
if (sequence !== requestSequence.value) return;
errorMessage.value = await errorForResponse(error);
@@ -171,10 +167,6 @@ const handleServerChange = () => {
const goBack = () => {
if (workspaceTaskController?.closeTransientTask(route.path)) return;
if (resourceType.value === "ledger_revision") {
void router.push(isTauriRuntime() ? "/desktop/project-entry" : "/workbench");
return;
}
router.back();
};
@@ -197,7 +189,7 @@ onDeactivated(() => {
onBeforeUnmount(() => {
destroyViewer();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
if (resourceType.value !== "ledger_revision") study.setViewContext(null);
study.setViewContext(null);
});
</script>
@@ -214,8 +206,6 @@ onBeforeUnmount(() => {
background: #f3f5f8;
}
.office-preview-page.is-ledger-preview { height: 100vh; height: 100dvh; }
.office-preview-page__header {
z-index: 1;
display: grid;
+47 -11
View File
@@ -53,10 +53,14 @@
<div class="project-header">
<div class="header-title-block">
<span class="section-eyebrow">Project Workspace</span>
<div class="entry-title-row">
<h2>选择目标项目</h2>
<EntryCountBadge v-if="!loading && projects.length">{{ projects.length }} 个可进入项目</EntryCountBadge>
</div>
<h2>选择目标项目</h2>
</div>
<div class="header-actions-block">
<span v-if="!loading && projects.length" class="project-pill-badge">
<span class="badge-dot"></span>
<span>{{ projects.length }} 个可进入项目</span>
</span>
</div>
</div>
@@ -151,7 +155,6 @@
<h3>未检测到可用项目</h3>
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
</div>
<LedgerPanel />
</main>
<!-- 科技感转场遮罩 -->
@@ -188,9 +191,6 @@ import type { Study } from "../types/api";
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
import { isSystemAdmin } from "../utils/roles";
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
import LedgerPanel from "../components/ledgers/LedgerPanel.vue";
import EntryCountBadge from "../components/EntryCountBadge.vue";
import "../styles/entry-cards.css";
const auth = useAuthStore();
const studyStore = useStudyStore();
@@ -327,7 +327,7 @@ onMounted(() => {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 24px 28px 40px;
padding: 40px 28px;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
border-right: 1px solid #e2e8f0;
box-shadow: 6px 0 30px rgba(15, 23, 42, 0.02);
@@ -537,7 +537,7 @@ onMounted(() => {
z-index: 5;
display: flex;
flex-direction: column;
padding: 24px 48px 48px 40px;
padding: 48px 48px 48px 40px;
overflow-y: auto;
}
@@ -550,7 +550,7 @@ onMounted(() => {
}
.header-title-block h2 {
margin: 0;
margin: 2px 0 0;
color: #0f172a;
font-size: 22px;
font-weight: 800;
@@ -577,6 +577,33 @@ onMounted(() => {
content: "";
}
.header-actions-block {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.project-pill-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
font-size: 11.5px;
font-weight: 700;
}
.badge-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #3b82f6;
}
/* 网页端特有步骤条 - 已根据用户要求隐藏,保留 HTML 结构供测试匹配断言 */
.workflow-strip {
display: none !important;
@@ -636,6 +663,8 @@ onMounted(() => {
position: relative;
display: flex;
flex-direction: column;
min-height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 12px;
background: #ffffff;
@@ -841,6 +870,8 @@ onMounted(() => {
/* 骨架屏 */
.skeleton-project-card {
height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
background: #ffffff;
@@ -967,6 +998,11 @@ onMounted(() => {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .project-pill-badge) {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
:global([data-ctms-theme="dark"] .workflow-item) {
background: rgba(15, 23, 42, 0.45);
border-color: rgba(51, 65, 85, 0.4);
@@ -2,11 +2,7 @@ import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = ["./CollaborationLibrary.vue", "../../components/collaboration/CollaborationAccessDialog.vue", "../../components/collaboration/CollaborationHistoryDialog.vue"]
.map((file) => readFileSync(resolve(__dirname, file), "utf8")).join("\n");
const accessApiSource = readFileSync(resolve(__dirname, "../../api/collaborationAccess.ts"), "utf8");
const historyApiSource = readFileSync(resolve(__dirname, "../../api/collaborationHistory.ts"), "utf8");
const librarySource = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
const source = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
describe("CollaborationLibrary UI contract", () => {
it("opens the create dialog directly from the primary sidebar action", () => {
@@ -77,17 +73,16 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain("R${");
expect(source).toContain("downloadCollaborationFile");
expect(source).toContain("downloadFileWithFeedback(");
expect(librarySource).not.toContain("prepareSaveFile");
expect(source).not.toContain("prepareSaveFile");
});
it("refreshes history immediately after restoring a revision", () => {
expect(source).toContain("const restoringRevisionId = ref<string | null>(null)");
expect(source).toContain(':loading="restoringRevisionId === row.id"');
expect(source).toContain("const { data: restored } = await api.restore");
expect(historyApiSource).toContain("restoreCollaborationRevision(target.studyId");
expect(source).toContain("const { data: restored } = await restoreCollaborationRevision");
expect(source).toContain("current_revision_id: restoredForDisplay.id");
expect(source).toContain("...revisions.value.filter((item) => item.id !== restoredForDisplay.id)");
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
expect(source).toContain("const [revisionResponse] = await Promise.all([");
expect(source).toContain("if (refreshedFile) historyFile.value = refreshedFile");
});
@@ -97,7 +92,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("scheduleHistoryRefresh();");
expect(source).toContain("refreshHistory({ silent: true })");
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
expect(historyApiSource).toContain("fetchCollaborationFile(target.studyId, id)");
expect(source).toContain("fetchCollaborationFile(requireStudyId(), fileId)");
expect(source).toContain("historyFile.value = fileResponse.data");
expect(source).toContain("requestSequence !== historyRequestSequence");
});
@@ -124,11 +119,11 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("nameRevision(row)");
expect(source).toContain("previewRevision(row)");
expect(source).toContain("saveRevisionAs(row)");
expect(source).toContain('"LedgerRevisionPreview" : "OfficeCollaborationRevisionPreview"');
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source).toContain('window.open(previewRoute.href, "_blank", "noopener,noreferrer")');
expect(source).not.toContain("historyDialogVisible.value = false");
expect(source).toContain("copyCollaborationRevision");
expect(historyApiSource).toContain("updateCollaborationRevision");
expect(source).toContain("updateCollaborationRevision");
expect(source).toContain('v-model="revisionSaveAsDialogVisible"');
expect(source).toContain("<CollaborationSaveAsDialog");
expect(source).toContain(':initial-folder-id="revisionSaveAsFolderId"');
@@ -139,7 +134,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain('PERMISSION_CHANGE: "权限设置"');
expect(source).toContain('command="delete"');
expect(source).toContain("删除版本");
expect(source).toContain('v-if="target.scope === \'project\' && historyFile?.can_manage" command="delete"');
expect(source).toContain('v-if="historyFile?.can_manage" command="delete"');
expect(source).toContain("deleteCollaborationRevision");
expect(source).toContain("revision.id === historyFile.value.current_revision_id");
expect(source).toContain("height: min(760px, 82vh)");
@@ -158,7 +153,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("<span>管理协作者</span>");
expect(source).toContain('class="access-member-identity"');
expect(source).toContain('class="access-member-role"');
expect(source).toContain('class="access-member-owner">{{ member.protected_label || "所有者" }}</span>');
expect(source).toContain('class="access-member-owner">所有者</span>');
expect(source).not.toContain('<el-table :data="members"');
expect(source).toContain('v-model="memberInviteDialogVisible"');
expect(source).toContain('title="管理协作者"');
@@ -167,7 +162,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain('member-picker-group--recent');
expect(source).not.toContain('member-picker-recent-empty');
expect(source).not.toContain('最近选择的账号将显示在这里');
expect(source).toContain('role="listbox" :aria-label="`可添加的${directoryLabel}`"');
expect(source).toContain('role="listbox" aria-label="可添加的项目成员"');
expect(source).toContain("memberForm.user_ids.includes(candidate.user_id)");
expect(source).toContain("toggleMemberCandidate(candidate.user_id)");
expect(source).toContain("selectedMemberCandidates.length");
@@ -196,7 +191,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("允许申请编辑权限");
expect(source).toContain("handleEditRequestPermissionChange");
expect(source).toContain("待处理申请");
expect(accessApiSource).toContain("resolveCollaborationEditRequest");
expect(source).toContain("resolveCollaborationEditRequest");
expect(source).toContain("notifyProjectNotificationsChanged");
expect(source).toContain("route.query.editRequestFile");
expect(source).toContain("openEditRequestNotificationTarget");
@@ -226,7 +221,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain(':disabled="!selectedTransferCandidate"');
expect(source).toContain('>确定</el-button>');
expect(source).not.toContain('accessPanel === "transfer"');
expect(accessApiSource).toContain("transferCollaborationOwnership");
expect(source).toContain("transferCollaborationOwnership");
});
it("saves permission switches silently while retaining failure feedback", () => {
@@ -271,8 +266,8 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain("同时约束账号授权和匿名访问");
expect(source).toContain('@change="persistShareSettings()"');
expect(source).not.toContain("保存链接设置");
expect(accessApiSource).toContain("fetchCollaborationShareLink");
expect(accessApiSource).toContain("updateCollaborationShareLink");
expect(source).toContain("fetchCollaborationShareLink");
expect(source).toContain("updateCollaborationShareLink");
expect(source).not.toContain("regenerateCollaborationShareLink");
expect(source).not.toContain("重新生成链接");
expect(source).not.toContain("关闭后重新开启仍使用同一地址");
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,6 @@
'is-fullscreen': webFullscreenActive,
'is-fullscreen-toolbar-visible': fullscreenToolbarVisible,
'is-macos-desktop': isMacDesktop,
'is-ledger-workspace': isLedgerWorkspace,
}"
>
<div
@@ -84,7 +83,6 @@
</div>
</main>
<CollaborationSaveAsDialog
v-if="!isLedgerWorkspace"
v-model="saveAsDialogVisible"
:study-id="studyId"
:initial-title="saveAsTitle"
@@ -119,8 +117,6 @@ import {
recordCollaborationDownload,
} from "../../api/collaboration";
import { useStudyStore } from "../../store/study";
import { checkLedgerDownload, createLedgerEditRequest, fetchLedger, fetchLedgerEditorConfig } from "../../api/ledgers";
import type { Ledger } from "../../types/ledger";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getRuntimePlatform,
@@ -146,10 +142,7 @@ const router = useRouter();
const study = useStudyStore();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const { can } = usePermission();
const isLedgerWorkspace = computed(() => route.meta.ledgerWorkspace === true);
type WorkspaceFile = Pick<CollaborationFile, "title" | "can_edit"> & Partial<Pick<CollaborationFile, "folder_id" | "edit_request_status" | "can_request_edit">>;
const file = ref<WorkspaceFile | null>(null);
const ledgerSnapshot = ref<Ledger | null>(null);
const file = ref<CollaborationFile | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const errorMessage = ref("");
const warningMessage = ref("");
@@ -173,8 +166,6 @@ const isMacDesktop = isTauriRuntime() && getRuntimePlatform() === "macos";
let mounted = false;
let webFullscreenUnlisten: (() => void) | null = null;
let fullscreenToolbarHideTimer: number | undefined;
let ledgerCheckTimer: number | undefined;
let checkingLedger = false;
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
@@ -287,7 +278,7 @@ const destroyEditor = () => {
};
const loadWorkspace = async () => {
if ((!isLedgerWorkspace.value && !studyId.value) || !fileId.value) return;
if (!studyId.value || !fileId.value) return;
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
destroyEditor();
@@ -296,19 +287,17 @@ const loadWorkspace = async () => {
warningMessage.value = "";
loading.value = true;
status.value = "loading";
ledgerSnapshot.value = null;
try {
const [fileResponse, configResponse] = await Promise.all([
isLedgerWorkspace.value ? fetchLedger(fileId.value) : fetchCollaborationFile(studyId.value, fileId.value),
isLedgerWorkspace.value ? fetchLedgerEditorConfig(fileId.value) : fetchCollaborationEditorConfig(studyId.value, fileId.value),
fetchCollaborationFile(studyId.value, fileId.value),
fetchCollaborationEditorConfig(studyId.value, fileId.value),
]);
if (requestSequence.value !== sequence) return;
if (configResponse.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
file.value = fileResponse.data;
editorConfig.value = configResponse.data;
viewerSequence.value += 1;
if (isLedgerWorkspace.value) ledgerSnapshot.value = fileResponse.data as Ledger;
else study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
@@ -318,29 +307,6 @@ const loadWorkspace = async () => {
}
};
const checkLedgerSession = async () => {
if (!isLedgerWorkspace.value || !editorConfig.value || !ledgerSnapshot.value || loading.value || checkingLedger) return;
const sequence = requestSequence.value;
checkingLedger = true;
try {
const { data } = await fetchLedger(fileId.value);
if (sequence !== requestSequence.value) return;
if (data.generation !== ledgerSnapshot.value?.generation || data.role !== ledgerSnapshot.value.role) {
destroyEditor();
errorMessage.value = "台账权限或内容版本已更新,请重新打开台账";
status.value = "error";
} else {
file.value = data;
if (editorConfig.value) editorConfig.value.can_request_edit = data.can_request_edit;
}
} catch (error) {
if (sequence !== requestSequence.value) return;
destroyEditor();
errorMessage.value = await getApiErrorMessage(error, "无法确认台账访问权限,请检查连接后重试");
status.value = "error";
} finally { checkingLedger = false; }
};
const eventMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
@@ -366,8 +332,7 @@ const requestEditRights = async () => {
if (requestingEdit.value || accessMode.value === "edit" || file.value?.edit_request_status === "PENDING") return;
requestingEdit.value = true;
try {
if (isLedgerWorkspace.value) await createLedgerEditRequest(fileId.value);
else await createCollaborationEditRequest(studyId.value, fileId.value);
await createCollaborationEditRequest(studyId.value, fileId.value);
if (file.value) {
file.value = { ...file.value, can_request_edit: false, edit_request_status: "PENDING" };
}
@@ -444,7 +409,6 @@ const savePendingDownload = async () => {
}
return;
}
if (isLedgerWorkspace.value) await checkLedgerDownload(fileId.value, extension);
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
@@ -460,7 +424,7 @@ const savePendingDownload = async () => {
pendingDownload.value = null;
downloadTitle.value = "";
try {
if (!isLedgerWorkspace.value) await recordCollaborationDownload(studyId.value, fileId.value, extension);
await recordCollaborationDownload(studyId.value, fileId.value, extension);
} catch {
ElMessage.warning("文件已下载,但下载审计记录失败");
}
@@ -500,10 +464,6 @@ const handleServerChange = () => {
const goBack = async () => {
await exitWorkspaceFullscreen();
if (isLedgerWorkspace.value) {
await router.push(isTauriRuntime() ? "/desktop/project-entry" : "/workbench");
return;
}
if (workspaceTaskController?.closeTransientTask(route.path)) return;
await router.push("/knowledge/collaboration");
};
@@ -514,8 +474,6 @@ onMounted(() => {
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
ledgerCheckTimer = window.setInterval(() => { void checkLedgerSession(); }, 15000);
window.addEventListener("focus", checkLedgerSession);
void loadWorkspace();
});
onActivated(() => {
@@ -529,17 +487,14 @@ onDeactivated(() => {
errorMessage.value = "";
});
onBeforeUnmount(() => {
if (ledgerCheckTimer !== undefined) window.clearInterval(ledgerCheckTimer);
window.removeEventListener("focus", checkLedgerSession);
cancelFullscreenToolbarHide();
webFullscreenUnlisten?.();
webFullscreenUnlisten = null;
void exitWorkspaceFullscreen();
destroyEditor();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
if (!isLedgerWorkspace.value) study.setViewContext(null);
study.setViewContext(null);
});
watch(fileId, () => { if (mounted) void loadWorkspace(); });
</script>
<style scoped>
@@ -556,7 +511,6 @@ watch(fileId, () => { if (mounted) void loadWorkspace(); });
background: #f3f5f8;
}
.collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }
.collaboration-workspace.is-ledger-workspace { height: 100dvh; }
.collaboration-workspace__fullscreen-hotzone {
position: absolute;
z-index: 20;
@@ -1,99 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import Workspace from "./CollaborationWorkspace.vue";
import { createLedgerEditRequest, fetchLedger, fetchLedgerEditorConfig } from "../../api/ledgers";
import { createCollaborationEditRequest, fetchCollaborationFile } from "../../api/collaboration";
const state = vi.hoisted(() => ({ push: vi.fn(), setViewContext: vi.fn() }));
vi.mock("vue-router", () => ({
useRoute: () => ({ meta: { ledgerWorkspace: true }, params: { fileId: "ledger-a" }, path: "/ledgers/ledger-a" }),
useRouter: () => ({ push: state.push }),
}));
vi.mock("../../store/study", () => ({ useStudyStore: () => ({ currentStudy: null, setViewContext: state.setViewContext }) }));
vi.mock("../../utils/permission", () => ({ usePermission: () => ({ can: () => false }) }));
vi.mock("../../utils/fileTaskFeedback", () => ({ saveFileWithFeedback: vi.fn() }));
vi.mock("../../api/ledgers", () => ({ fetchLedger: vi.fn(), fetchLedgerEditorConfig: vi.fn(), checkLedgerDownload: vi.fn(), createLedgerEditRequest: vi.fn() }));
vi.mock("../../api/collaboration", () => ({
fetchCollaborationFile: vi.fn(), fetchCollaborationEditorConfig: vi.fn(), createCollaborationEditRequest: vi.fn(),
importCollaborationFile: vi.fn(), recordCollaborationDownload: vi.fn(),
}));
vi.mock("../../runtime", () => ({
DESKTOP_SERVER_URL_CHANGED_EVENT: "server-changed", getRuntimePlatform: () => "web", isTauriRuntime: () => false,
isWebFullscreenActive: () => false, isWebFullscreenAvailable: () => false, listenWebFullscreenChange: () => () => {},
ONLYOFFICE_HOST_PATH: "/onlyoffice-host.html", prepareSaveFile: vi.fn(),
refreshWebFullscreenState: async () => {}, toggleWebFullscreen: vi.fn(),
}));
vi.mock("../../components/collaboration/CollaborationSaveAsDialog.vue", () => ({ default: { template: '<div />' } }));
vi.mock("../../components/collaboration/CollaborationDownloadDialog.vue", () => ({ default: { template: '<div />' } }));
vi.mock("../../components/OnlyOfficeViewer.vue", () => ({ default: { name: "OnlyOfficeViewer", props: ["config"], template: '<div data-test="editor" />' } }));
const ledger = { id: "ledger-a", title: "医学事务部临床运营项目编号.xlsx", can_edit: true, role: "EDITOR", generation: 4 };
let wrapper: ReturnType<typeof mount> | undefined;
const render = () => {
wrapper = mount(Workspace, { global: { stubs: {
ElButton: { template: '<button><slot /></button>' }, ElIcon: { template: '<span><slot /></span>' },
ElTooltip: { template: '<span><slot /></span>' },
} } });
return wrapper;
};
describe("ledger collaboration workspace", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(fetchLedger).mockResolvedValue({ data: ledger } as any);
vi.mocked(fetchLedgerEditorConfig).mockResolvedValue({ data: {
file_name: ledger.title, host_path: "/onlyoffice-host.html", access_mode: "edit", can_download: false,
can_save_as: false, can_request_edit: false, config: {},
} } as any);
});
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.useRealTimers(); });
it("uses global APIs and returns to the workbench with no project context", async () => {
const page = render();
await flushPromises();
expect(page.find('[data-test="editor"]').exists()).toBe(true);
expect(fetchLedgerEditorConfig).toHaveBeenCalledWith("ledger-a");
expect(fetchCollaborationFile).not.toHaveBeenCalled();
expect(state.setViewContext).not.toHaveBeenCalled();
await page.get(".collaboration-workspace__back").trigger("click");
expect(state.push).toHaveBeenCalledWith("/workbench");
});
it("destroys the old editor when permissions or generation change", async () => {
const page = render();
await flushPromises();
vi.mocked(fetchLedger).mockResolvedValue({ data: { ...ledger, generation: 5 } } as any);
await vi.advanceTimersByTimeAsync(15000);
await flushPromises();
expect(page.find('[data-test="editor"]').exists()).toBe(false);
expect(page.text()).toContain("台账权限或内容版本已更新");
});
it("removes the editor if access can no longer be verified", async () => {
const page = render();
await flushPromises();
vi.mocked(fetchLedger).mockRejectedValue(new Error("denied"));
await vi.advanceTimersByTimeAsync(15000);
await flushPromises();
expect(page.find('[data-test="editor"]').exists()).toBe(false);
expect(page.text()).toContain("无法打开协作文件");
});
it("submits a viewer edit request without project APIs and refreshes its pending status", async () => {
const viewer = { ...ledger, role: "VIEWER", can_edit: false, can_request_edit: true };
vi.mocked(fetchLedger).mockResolvedValue({ data: viewer } as any);
vi.mocked(fetchLedgerEditorConfig).mockResolvedValue({ data: {
file_name: ledger.title, host_path: "/onlyoffice-host.html", access_mode: "view", can_request_edit: true, config: {},
} } as any);
vi.mocked(createLedgerEditRequest).mockResolvedValue({ data: { status: "PENDING" } } as any);
const page = render();
await flushPromises();
await page.findAll("button").find((button) => button.text() === "申请编辑权限")!.trigger("click");
await flushPromises();
expect(createLedgerEditRequest).toHaveBeenCalledWith("ledger-a");
expect(createCollaborationEditRequest).not.toHaveBeenCalled();
expect(page.text()).toContain("编辑权限申请处理中");
vi.mocked(fetchLedger).mockResolvedValue({ data: { ...viewer, edit_request_status: "REJECTED" } } as any);
await vi.advanceTimersByTimeAsync(15000);
await flushPromises();
expect(page.text()).not.toContain("编辑权限申请处理中");
expect(page.find('[data-test="editor"]').exists()).toBe(true);
});
});
+2 -2
View File
@@ -3,8 +3,8 @@ FROM ${ONLYOFFICE_IMAGE}
USER root
# Use redistributable Noto CJK fonts for Chinese document preview. Do not add
# deployment-supplied proprietary Microsoft or Zhongyi fonts to this build.
# Use redistributable Noto CJK fonts for Chinese document preview. Proprietary
# Microsoft fonts must be supplied separately by an authorized deployment.
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
+7 -43
View File
@@ -62,9 +62,9 @@ ${C_BOLD}环境:${C_RESET}
${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对)
${C_BOLD}选项:${C_RESET}
${C_WHITE}--base-url <url>${C_RESET} 对外访问基址(同时用于健康检查与 ONLYOFFICE 回调校验)
${C_WHITE}--base-url <url>${C_RESET} 健康检查使用的访问地址
dev/main 默认 http://127.0.0.1:8888
已有环境优先复用 .env;首次 release 安装必须提供
release 必须通过此参数或交互输入提供
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
${C_WHITE}--skip-build${C_RESET} 跳过镜像构建,仍会启动容器(docker compose up -d)
${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head)
@@ -179,25 +179,16 @@ generate_onlyoffice_instance_id() {
# ── 地址解析 ─────────────────────────────────
resolve_base_url() {
if [[ -z "$BASE_URL" && -f "$ENV_FILE" ]]; then
BASE_URL="$(read_env_value CTMS_BASE_URL || true)"
[[ -n "$BASE_URL" ]] || BASE_URL="$(read_env_value FRONTEND_PUBLIC_URL || true)"
fi
[[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then
printf ' %s▸%s 请输入 release 对外访问基址(例如 https://ctms.example.com): ' \
printf ' %s▸%s 请输入 release 健康检查域名(例如 https://ctms.example.com): ' \
"${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -r BASE_URL || true
fi
[[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址"
BASE_URL="${BASE_URL%/}"
if [[ ! "$BASE_URL" =~ ^https?://[^/?#]+$ || "$BASE_URL" == *"@"* ]]; then
fail "对外访问基址必须是仅包含协议和主机的 HTTP(S) origin,例如 https://ctms.example.com"
fi
if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then
fail "release 环境的访问地址必须使用 HTTPS"
fi
@@ -231,7 +222,7 @@ confirm_install() {
row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}"
row "运行模式" "$runtime_env"
row "Compose 项目" "$project_name"
row "对外访问基址" "$BASE_URL" "${CC_INFO}"
row "健康检查地址" "$BASE_URL" "${CC_INFO}"
row ".env 文件" "$env_status"
row "pg_data 目录" "$pg_status"
row "ONLYOFFICE" "标准组件(自动安装)" "${CC_INFO}"
@@ -385,23 +376,6 @@ run_backend_init() {
fi
}
check_onlyoffice_active_sessions() {
step "检查 ONLYOFFICE 在线编辑会话"
local running output
running="$(compose_cmd ps --services --filter status=running 2>/dev/null || true)"
if ! printf '%s\n' "$running" | grep -qx "onlyoffice"; then
ok "ONLYOFFICE 尚未运行,跳过在线编辑会话检查"
return 0
fi
if output="$(compose_cmd run --rm --no-deps backend-init \
python scripts/check_onlyoffice_active_sessions.py 2>&1)"; then
ok "$output"
return 0
fi
printf '%s\n' "$output" >&2
fail "部署前置检查未通过;为避免在线文件保存失败,尚未重启任何业务服务"
}
run_build_and_start() {
if [[ "$SKIP_BUILD" -eq 1 ]]; then
step "启动服务(跳过镜像构建)"
@@ -449,7 +423,7 @@ check_container_status() {
check_backend_environment() {
step "校验后端运行时环境变量"
local expected_env="$1" expected_key_id="$2" expect_rsa="$3" expected_public_url="$4"
local expected_env="$1" expected_key_id="$2" expect_rsa="$3"
local check_code
check_code=$(cat <<'PY'
import os, sys
@@ -459,7 +433,6 @@ from app.core.login_crypto import _normalize_pem
expected_env = os.environ["EXPECTED_ENV"]
expected_key_id = os.environ["EXPECTED_KEY_ID"]
expect_rsa = os.environ["EXPECT_RSA"] == "1"
expected_public_url = os.environ["EXPECTED_PUBLIC_URL"].rstrip("/")
if settings.ENV != expected_env:
sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}")
@@ -475,11 +448,6 @@ if settings.ONLYOFFICE_JWT_SECRET == settings.JWT_SECRET_KEY:
sys.exit("ONLYOFFICE_JWT_SECRET 不得复用登录 JWT 密钥")
if not settings.ONLYOFFICE_INSTANCE_ID:
sys.exit("ONLYOFFICE_INSTANCE_ID 未配置")
if settings.FRONTEND_PUBLIC_URL.rstrip("/") != expected_public_url:
sys.exit(
"FRONTEND_PUBLIC_URL 不匹配;ONLYOFFICE 公开缓存地址会被保存回调拒绝: "
f"{settings.FRONTEND_PUBLIC_URL} != {expected_public_url}"
)
if expect_rsa:
if not settings.LOGIN_RSA_PRIVATE_KEY:
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
@@ -494,7 +462,6 @@ PY
-e EXPECTED_ENV="$expected_env" \
-e EXPECTED_KEY_ID="$expected_key_id" \
-e EXPECT_RSA="$expect_rsa" \
-e EXPECTED_PUBLIC_URL="$expected_public_url" \
backend python -c "$check_code"
}
@@ -541,7 +508,7 @@ run_health_checks() {
[[ "$runtime_env" == "production" ]] && expect_rsa=1
check_container_status
check_dev_nginx_mode
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa" "$BASE_URL"
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
step "探测 HTTP 接口可用性"
check_http_endpoint "/health"
check_http_endpoint "/readyz"
@@ -588,13 +555,10 @@ main() {
confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV"
prepare_env_file "$project_name" "$runtime_env" "$login_key_id"
sync_frontend_build_env "$runtime_env"
# 对外访问基址同时用于健康检查和后端校验 ONLYOFFICE 返回的公开缓存 URL。
# 两项必须同步;否则生产域名下的最终保存回调会被当作不受信任地址拒绝。
# 健康检查地址持久化到 .env(单一事实来源);独立 upsert,绕开 prepare_env_file 对已存在 .env 的跳过。
ctms_upsert_env_value CTMS_BASE_URL "$BASE_URL"
ctms_upsert_env_value FRONTEND_PUBLIC_URL "$BASE_URL"
run_compose_config
run_backend_init
check_onlyoffice_active_sessions
run_build_and_start
run_migrations
resolve_effective_config "$project_name" "$runtime_env" "$login_key_id"