Compare commits
18 Commits
6b55c18610
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 795e0a75ca | |||
| e3489f3b06 | |||
| c23a5d6a22 | |||
| fa26c84bd2 | |||
| c5c9bb5527 | |||
| feb1f3db65 | |||
| de3dc87920 | |||
| a0aa9384d7 | |||
| a938d2ed5f | |||
| 4863ade45b | |||
| d5279b124f | |||
| 1837ceff58 | |||
| 3e77127687 | |||
| 88bd0c5942 | |||
| 32167fba02 | |||
| 18a3166463 | |||
| 2d7d13b2f5 | |||
| b491b6a146 |
@@ -40,6 +40,8 @@ jobs:
|
||||
web:
|
||||
name: Shared client and Web
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
@@ -104,6 +106,8 @@ jobs:
|
||||
desktop:
|
||||
name: macOS Desktop
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
|
||||
@@ -14,9 +14,40 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-release-candidate-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-policy:
|
||||
name: Resolve desktop release signing policy
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
platform_signing_mode: ${{ steps.policy.outputs.platform_signing_mode }}
|
||||
macos_signing: ${{ steps.policy.outputs.macos_signing }}
|
||||
windows_signing: ${{ steps.policy.outputs.windows_signing }}
|
||||
artifact_label: ${{ steps.policy.outputs.artifact_label }}
|
||||
warning_required: ${{ steps.policy.outputs.warning_required }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- name: Checkout release source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.13"
|
||||
|
||||
- name: Resolve version-scoped signing policy
|
||||
id: policy
|
||||
shell: bash
|
||||
run: npm run desktop:release-policy:check -- --require-tag --github-output "${GITHUB_OUTPUT}"
|
||||
|
||||
macos-release-candidate:
|
||||
name: Signed macOS release candidate
|
||||
name: macOS release candidate
|
||||
needs: release-policy
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -24,8 +55,13 @@ jobs:
|
||||
env:
|
||||
VITE_BUILD_CHANNEL: release
|
||||
VITE_BUILD_COMMIT: ${{ github.sha }}
|
||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||
RELEASE_BUILD: "true"
|
||||
REQUIRE_DESKTOP_SIGNING: "true"
|
||||
DESKTOP_RELEASE_PLATFORM: macos
|
||||
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
|
||||
REQUIRE_UPDATER_SIGNING: "true"
|
||||
REQUIRE_DESKTOP_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
|
||||
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
|
||||
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -50,7 +86,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
||||
echo "Signed desktop release candidates must run from a vX.Y.Z tag."
|
||||
echo "Desktop release candidates must run from a vX.Y.Z tag."
|
||||
exit 1
|
||||
fi
|
||||
expected_tag="v$(node -p "require('./package.json').version")"
|
||||
@@ -67,10 +103,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Audit dependencies
|
||||
run: npm audit
|
||||
|
||||
- name: Check release build metadata and signing environment
|
||||
run: npm run release:env:check
|
||||
|
||||
- name: Check signed desktop release readiness
|
||||
- name: Check desktop release readiness
|
||||
run: npm run desktop:release-readiness:check
|
||||
|
||||
- name: Check synchronized client version
|
||||
@@ -94,41 +133,324 @@ jobs:
|
||||
- name: Build Web artifact
|
||||
run: npm run build
|
||||
|
||||
- name: Build signed Universal macOS artifacts
|
||||
- name: Build Apple-signed and notarized Universal macOS artifacts
|
||||
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
||||
run: npm run desktop:build:macos-release -- --ci
|
||||
|
||||
- name: Create desktop update feed
|
||||
- name: Build ad-hoc Universal macOS artifacts
|
||||
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
|
||||
run: npm run desktop:build:macos-unsigned-release -- --ci
|
||||
|
||||
- name: Verify and stage macOS artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -z "${DESKTOP_UPDATE_BASE_URL}" ]]; then
|
||||
echo "DESKTOP_UPDATE_BASE_URL or workflow input artifact_base_url is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app="$(find src-tauri/target -path '*/release/bundle/macos/*.app' -print -quit)"
|
||||
artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)"
|
||||
if [[ -z "${artifact}" ]]; then
|
||||
echo "No macOS updater artifact was produced."
|
||||
dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
|
||||
if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then
|
||||
echo "macOS app, updater artifact/signature, and DMG are all required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
include_args=()
|
||||
dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
|
||||
if [[ -n "${dmg}" ]]; then
|
||||
include_args+=(--include "${dmg}")
|
||||
codesign --verify --deep --strict --verbose=2 "${app}"
|
||||
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "signed" ]]; then
|
||||
spctl --assess --type execute --verbose=2 "${app}"
|
||||
xcrun stapler validate "${app}"
|
||||
xcrun stapler validate "${dmg}"
|
||||
else
|
||||
signature_info="$(codesign -dv --verbose=4 "${app}" 2>&1)"
|
||||
if ! grep -q "Signature=adhoc" <<<"${signature_info}"; then
|
||||
echo "The macOS unsigned-platform exception must produce an ad-hoc signed app."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
stage="src-tauri/target/desktop-release-macos"
|
||||
mkdir -p "${stage}"
|
||||
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "unsigned-exception" ]]; then
|
||||
artifact_name="$(basename "${artifact}" .app.tar.gz)_UNSIGNED.app.tar.gz"
|
||||
dmg_name="$(basename "${dmg}" .dmg)_UNSIGNED.dmg"
|
||||
cp "${artifact}" "${stage}/${artifact_name}"
|
||||
cp "${artifact}.sig" "${stage}/${artifact_name}.sig"
|
||||
cp "${dmg}" "${stage}/${dmg_name}"
|
||||
cp desktop-release-unsigned-warning.txt "${stage}/UNSIGNED-PLATFORM-RELEASE.txt"
|
||||
else
|
||||
cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/"
|
||||
fi
|
||||
|
||||
- name: Upload macOS candidate artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||
path: frontend/src-tauri/target/desktop-release-macos/*
|
||||
if-no-files-found: error
|
||||
|
||||
windows-release-candidate:
|
||||
name: Windows release candidate
|
||||
needs: release-policy
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
env:
|
||||
VITE_BUILD_CHANNEL: release
|
||||
VITE_BUILD_COMMIT: ${{ github.sha }}
|
||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||
RELEASE_BUILD: "true"
|
||||
DESKTOP_RELEASE_PLATFORM: windows
|
||||
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
|
||||
REQUIRE_UPDATER_SIGNING: "true"
|
||||
REQUIRE_WINDOWS_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
|
||||
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
|
||||
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }}
|
||||
steps:
|
||||
- name: Checkout release source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.13"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Enforce release tag context
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:GITHUB_REF_TYPE -ne "tag") {
|
||||
throw "Desktop release candidates must run from a vX.Y.Z tag."
|
||||
}
|
||||
$expectedTag = "v$(node -p "require('./package.json').version")"
|
||||
if ($env:GITHUB_REF_NAME -ne $expectedTag) {
|
||||
throw "Release tag $env:GITHUB_REF_NAME does not match package version $expectedTag."
|
||||
}
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Audit dependencies
|
||||
run: npm audit
|
||||
|
||||
- name: Check release build metadata and signing environment
|
||||
run: npm run release:env:check
|
||||
|
||||
- name: Check desktop release readiness
|
||||
run: npm run desktop:release-readiness:check
|
||||
|
||||
- name: Check synchronized client version
|
||||
run: npm run version:check
|
||||
|
||||
- name: Check runtime boundary
|
||||
run: npm run runtime:check
|
||||
|
||||
- name: Check desktop release and security gate
|
||||
run: npm run desktop:release:check
|
||||
|
||||
- name: Check UI contract
|
||||
run: npm run ui:contract
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Build Web artifact
|
||||
run: npm run build
|
||||
|
||||
- name: Import Windows code-signing certificate
|
||||
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx"
|
||||
$encodedCertificate = $env:WINDOWS_CERTIFICATE `
|
||||
-replace "-----BEGIN [^-]+-----", "" `
|
||||
-replace "-----END [^-]+-----", "" `
|
||||
-replace "\s", ""
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($encodedCertificate))
|
||||
$password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
|
||||
$importedCertificates = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\CurrentUser\My" -Password $password -Exportable:$false
|
||||
$certificate = $importedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1
|
||||
if (-not $certificate -or -not $certificate.HasPrivateKey) {
|
||||
throw "The imported Windows code-signing certificate is missing its private key."
|
||||
}
|
||||
$now = Get-Date
|
||||
if ($certificate.NotBefore -gt $now -or $certificate.NotAfter -le $now) {
|
||||
throw "The Windows code-signing certificate is not currently valid."
|
||||
}
|
||||
$codeSigningEku = $certificate.EnhancedKeyUsageList | Where-Object { $_.ObjectId.Value -eq "1.3.6.1.5.5.7.3.3" }
|
||||
if (-not $codeSigningEku) {
|
||||
throw "The Windows certificate does not contain the Code Signing enhanced key usage."
|
||||
}
|
||||
"WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
Remove-Item $pfxPath -Force
|
||||
|
||||
- name: Write signed Windows Tauri config
|
||||
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$config = @{
|
||||
bundle = @{
|
||||
windows = @{
|
||||
certificateThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT
|
||||
digestAlgorithm = "sha256"
|
||||
timestampUrl = $env:WINDOWS_TIMESTAMP_URL
|
||||
tsp = $true
|
||||
}
|
||||
}
|
||||
} | ConvertTo-Json -Depth 8
|
||||
Set-Content -Path "tauri.windows.release.conf.json" -Value $config -Encoding utf8
|
||||
Get-Content "tauri.windows.release.conf.json"
|
||||
|
||||
- name: Build signed Windows NSIS artifacts
|
||||
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
||||
timeout-minutes: 30
|
||||
run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci
|
||||
|
||||
- name: Build unsigned Windows NSIS artifacts
|
||||
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
|
||||
timeout-minutes: 30
|
||||
run: npm run desktop:build:windows-release -- --ci
|
||||
|
||||
- name: Verify platform signing mode and stage Windows artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleDir = "src-tauri/target/release/bundle/nsis"
|
||||
$installers = @(Get-ChildItem -Path $bundleDir -Filter "*.exe" -File)
|
||||
$updaters = @(Get-ChildItem -Path $bundleDir -Filter "*.nsis.zip" -File)
|
||||
$appExecutables = @(Get-ChildItem -Path "src-tauri/target/release" -Filter "*.exe" -File)
|
||||
if ($installers.Count -ne 1 -or $updaters.Count -ne 1 -or $appExecutables.Count -eq 0) {
|
||||
throw "Exactly one NSIS installer/updater and at least one application executable are required."
|
||||
}
|
||||
|
||||
$signaturePath = "$($updaters[0].FullName).sig"
|
||||
if (-not (Test-Path $signaturePath) -or (Get-Item $signaturePath).Length -eq 0) {
|
||||
throw "The Windows updater signature is missing or empty."
|
||||
}
|
||||
|
||||
foreach ($executable in @($appExecutables + $installers)) {
|
||||
$signature = Get-AuthenticodeSignature -FilePath $executable.FullName
|
||||
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "signed") {
|
||||
if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) {
|
||||
throw "Authenticode signature or RFC 3161 timestamp is invalid for $($executable.FullName): $($signature.StatusMessage)"
|
||||
}
|
||||
} elseif ($signature.Status -ne "NotSigned") {
|
||||
throw "The Windows unsigned-platform exception must produce an Authenticode-unsigned executable: $($executable.FullName) returned $($signature.Status)."
|
||||
}
|
||||
}
|
||||
|
||||
$stage = "src-tauri/target/desktop-release-windows"
|
||||
New-Item -ItemType Directory -Path $stage -Force | Out-Null
|
||||
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "unsigned-exception") {
|
||||
$installerName = $installers[0].Name -replace '\.exe$', '_UNSIGNED.exe'
|
||||
$updaterName = $updaters[0].Name -replace '\.nsis\.zip$', '_UNSIGNED.nsis.zip'
|
||||
Copy-Item $installers[0].FullName -Destination (Join-Path $stage $installerName)
|
||||
Copy-Item $updaters[0].FullName -Destination (Join-Path $stage $updaterName)
|
||||
Copy-Item $signaturePath -Destination (Join-Path $stage "$updaterName.sig")
|
||||
Copy-Item "desktop-release-unsigned-warning.txt" -Destination (Join-Path $stage "UNSIGNED-PLATFORM-RELEASE.txt")
|
||||
} else {
|
||||
Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage
|
||||
}
|
||||
|
||||
- name: Remove Windows signing certificate
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) {
|
||||
Remove-Item "Cert:\CurrentUser\My\$env:WINDOWS_CERTIFICATE_THUMBPRINT" -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue
|
||||
|
||||
- name: Upload Windows candidate artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||
path: frontend/src-tauri/target/desktop-release-windows/*
|
||||
if-no-files-found: error
|
||||
|
||||
aggregate-release-candidate:
|
||||
name: Verify combined desktop release directory
|
||||
needs:
|
||||
- release-policy
|
||||
- macos-release-candidate
|
||||
- windows-release-candidate
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
env:
|
||||
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
||||
steps:
|
||||
- name: Checkout release source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.13"
|
||||
|
||||
- name: Download macOS candidate artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||
path: frontend/src-tauri/target/desktop-release-input/macos
|
||||
|
||||
- name: Download Windows candidate artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||
path: frontend/src-tauri/target/desktop-release-input/windows
|
||||
|
||||
- name: Create desktop release provenance
|
||||
run: npm run desktop:release-provenance:create -- --macos-signing "${{ needs.release-policy.outputs.macos_signing }}" --windows-signing "${{ needs.release-policy.outputs.windows_signing }}" --output src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json
|
||||
|
||||
- name: Create combined desktop update feed
|
||||
shell: bash
|
||||
run: |
|
||||
mapfile -t mac_artifacts < <(find src-tauri/target/desktop-release-input/macos -name '*.app.tar.gz' -type f)
|
||||
mapfile -t dmgs < <(find src-tauri/target/desktop-release-input/macos -name '*.dmg' -type f)
|
||||
mapfile -t windows_artifacts < <(find src-tauri/target/desktop-release-input/windows -name '*.nsis.zip' -type f)
|
||||
mapfile -t installers < <(find src-tauri/target/desktop-release-input/windows -name '*.exe' -type f)
|
||||
if [[ ${#mac_artifacts[@]} -ne 1 || ${#dmgs[@]} -ne 1 || ${#windows_artifacts[@]} -ne 1 || ${#installers[@]} -ne 1 ]]; then
|
||||
echo "Exactly one macOS updater/DMG and one Windows updater/installer are required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
include_args=(--include "${dmgs[0]}" --include "${installers[0]}" --include src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json)
|
||||
notes_args=()
|
||||
if [[ "${{ needs.release-policy.outputs.warning_required }}" == "true" ]]; then
|
||||
mapfile -t warnings < <(find src-tauri/target/desktop-release-input/macos -name 'UNSIGNED-PLATFORM-RELEASE.txt' -type f)
|
||||
if [[ ${#warnings[@]} -ne 1 ]]; then
|
||||
echo "The unsigned-platform release warning is required."
|
||||
exit 1
|
||||
fi
|
||||
include_args+=(--include "${warnings[0]}")
|
||||
notes_args+=(--notes "UNSIGNED PLATFORM RELEASE: macOS is ad-hoc signed and not notarized; Windows is not Authenticode-signed. Gatekeeper and SmartScreen warnings are expected. Verify tag, commit, updater signatures, and SHA256SUMS.txt before installation.")
|
||||
fi
|
||||
|
||||
npm run desktop:update-feed:create -- \
|
||||
--artifact "${artifact}" \
|
||||
--artifact "${mac_artifacts[0]}" \
|
||||
--platform-artifact "windows-x86_64=${windows_artifacts[0]}" \
|
||||
"${include_args[@]}" \
|
||||
"${notes_args[@]}" \
|
||||
--output-dir src-tauri/target/desktop-release-feed \
|
||||
--base-url "${DESKTOP_UPDATE_BASE_URL}"
|
||||
|
||||
- name: Verify desktop update feed
|
||||
run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}"
|
||||
- name: Verify combined desktop update feed
|
||||
run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}" --require-platform windows-x86_64 --require-provenance
|
||||
|
||||
- name: Upload verified desktop release directory
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ctms-desktop-release-${{ github.ref_name }}
|
||||
name: ctms-desktop-release-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||
path: frontend/src-tauri/target/desktop-release-feed/*
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -14,6 +14,8 @@ jobs:
|
||||
windows-internal:
|
||||
name: Windows NSIS internal validation
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
桌面端项目计划书已完成并移除;当前桌面端执行约束以本文件为准。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知、自动更新和桌面端安全边界。历史设计只作追溯参考,见 `docs/desktop-phase-1-design.md`、`docs/desktop-phase-2-design.md`、`docs/audits/desktop-release-stabilization-checklist.md`。
|
||||
|
||||
桌面端当前不是空白初始化项目。Tauri 基线、macOS 在线桌面壳和第二阶段原生能力主体已经形成;后续工作限于修复、稳定化、体验收口、发布准备、在线辅助缓存和 Windows 兼容验证。不得重新按第一阶段空白项目初始化 Tauri,不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。
|
||||
桌面端当前不是空白初始化项目。Tauri 基线、macOS 在线桌面壳和第二阶段原生能力主体已经形成;后续工作限于修复、稳定化、体验收口、发布准备、在线辅助缓存、Windows 兼容验证和已批准的 Windows 正式发布。不得重新按第一阶段空白项目初始化 Tauri,不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。
|
||||
|
||||
除非用户明确要求先调整本文件中的约束,否则不要实现离线功能、本地业务权威数据存储、内嵌后端服务、离线同步、本地优先工作流或新的阶段性桌面产品线。在线辅助本地缓存只允许作为已认证在线客户端的体验加速能力;处理桌面本地缓存、请求去重、条件请求、缓存诊断或缓存清理相关任务时,必须阅读并遵守 `docs/desktop-local-cache-plan.md`。
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
|
||||
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。
|
||||
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
|
||||
- Windows 仍只作为第二阶段兼容性验证目标;未获明确批准前不发布正式 Windows 安装包。Windows 内测构建只能使用 `.github/workflows/desktop-windows-internal.yml` 的手动验证入口,不得生成生产 `latest.json`、updater feed 或正式发布制品。
|
||||
- 正式桌面客户端的默认 CTMS 服务端入口必须由构建环境变量 `VITE_DESKTOP_SERVER_URL` 注入,不得在运行时代码中写死生产域名;用户仍可在桌面服务器设置中手动覆盖,手动值优先并沿用既有的切换服务器登出与缓存失效边界。该变量只表示业务服务端 origin,不得与 updater 制品前缀 `DESKTOP_UPDATE_BASE_URL` 混用。
|
||||
- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建。平台签名默认要求组织 Windows 代码签名证书、RFC 3161 时间戳和 Authenticode 校验;只有 `frontend/desktop-release-policy.json` 中按精确版本记录、经发布负责人批准的例外可跳过平台签名。无论是否采用平台签名例外,都必须使用 updater 私钥、校验 updater feed,并明确标记平台未签名风险。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。
|
||||
- v0.1.0 另获准在 GitHub Actions 额度不可用时,从最终 `release` 上不可移动的 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS ad-hoc 制品;Windows 只能在额度恢复后从同一 tag/SHA 后补。面向安装用户的 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`。该本地/分阶段例外不适用于后续版本。
|
||||
|
||||
## 分支与发布治理
|
||||
|
||||
@@ -57,4 +59,4 @@ npm run desktop:build:app
|
||||
|
||||
本地缓存相关变更至少执行 `runtime:check`、`desktop:release:check`、`ui:contract`、`type-check`、`test:unit` 和 `build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
|
||||
|
||||
正式桌面发布构建仍必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述。
|
||||
正式桌面发布构建必须使用组织批准的 updater 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证、Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、Release Notes、私有发布证据、完整 updater 校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。面向安装用户的 GitHub Release 可按精确版本策略精简为安装包与对应 checksum,但不得因此删除私有 updater 签名制品或验证证据。该例外不自动适用于后续版本。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- 生产入口:`docker-compose.yaml`
|
||||
- 初始化方式:`docker compose run --rm --build backend-init`
|
||||
- 启动方式:`docker compose up -d --build`
|
||||
- 运行拓扑:`nginx`、`backend`、`db`
|
||||
- 运行拓扑:`nginx`、`backend`、`db`、`onlyoffice`
|
||||
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
|
||||
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
|
||||
- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
|
||||
@@ -50,11 +50,11 @@
|
||||
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `npm run version:check` 检查漂移。
|
||||
- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`。
|
||||
|
||||
## ONLYOFFICE 开发预览
|
||||
## ONLYOFFICE 标准组件
|
||||
|
||||
- 普通开发环境仍不强制启动 Document Server。
|
||||
- 需要 Office 只读预览时执行 `bash scripts/onlyoffice-dev-up.sh`。
|
||||
- 脚本会自动生成并持久化本机开发密钥,不需要手工填写;详细说明见 `docs/guides/onlyoffice-preview.md`。
|
||||
- `./install.sh` 或 `bash scripts/install.sh dev|main|release` 默认生成独立 JWT、持久化实例标识并启动 Document Server,不再需要额外启用 Compose profile。
|
||||
- `bash scripts/onlyoffice-dev-up.sh` 仅保留为开发环境重建、健康验证和密钥轮换入口;普通安装无需再执行。
|
||||
- 默认镜像、字体、安全配置和生产许可要求见 `docs/guides/onlyoffice-preview.md`。
|
||||
|
||||
## 仓库治理文档
|
||||
- 分支治理规范:`docs/branch-governance.md`
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Remove shared-library and passive Office preview audit noise.
|
||||
|
||||
Revision ID: 20260716_04
|
||||
Revises: 20260716_03
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260716_04"
|
||||
down_revision = "20260716_03"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
DELETE FROM audit_logs
|
||||
WHERE action = 'OFFICE_PREVIEW_OPEN'
|
||||
OR lower(entity_type) IN (
|
||||
'faq_category',
|
||||
'faq_item',
|
||||
'faq_reply',
|
||||
'faq_replies',
|
||||
'precaution',
|
||||
'knowledge_note',
|
||||
'knowledge_notes',
|
||||
'collaboration_file'
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 被清理的是明确排除出审计范围的共享库和被动预览记录,无法也不应伪造恢复。
|
||||
pass
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Expand generic notifications and route desktop delivery through them.
|
||||
|
||||
Revision ID: 20260716_05
|
||||
Revises: 20260716_04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "20260716_05"
|
||||
down_revision = "20260716_04"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"notifications",
|
||||
sa.Column("requires_action", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"notifications",
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=True)
|
||||
op.add_column(
|
||||
"desktop_notification_deliveries",
|
||||
sa.Column("notification_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_desktop_notification_deliveries_notification",
|
||||
"desktop_notification_deliveries",
|
||||
"notifications",
|
||||
["notification_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_desktop_notification_user_notification",
|
||||
"desktop_notification_deliveries",
|
||||
["user_id", "notification_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"uq_desktop_notification_user_notification",
|
||||
"desktop_notification_deliveries",
|
||||
type_="unique",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_desktop_notification_deliveries_notification",
|
||||
"desktop_notification_deliveries",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("desktop_notification_deliveries", "notification_id")
|
||||
# Legacy rows always have a distribution id; rows created by the generic delivery path do not.
|
||||
op.execute("DELETE FROM desktop_notification_deliveries WHERE distribution_id IS NULL")
|
||||
op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=False)
|
||||
op.drop_column("notifications", "due_at")
|
||||
op.drop_column("notifications", "requires_action")
|
||||
@@ -77,6 +77,7 @@ FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
|
||||
"read": "faq:read",
|
||||
"delete": "faq_attachments:delete",
|
||||
}
|
||||
SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}
|
||||
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
|
||||
"startup_kickoff",
|
||||
"startup_kickoff_minutes",
|
||||
@@ -239,16 +240,17 @@ async def upload_attachment(
|
||||
content_type=file.content_type,
|
||||
uploaded_by=current_user.id,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="UPLOAD_FILE",
|
||||
detail=f"文件已上传:{file.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="UPLOAD_FILE",
|
||||
detail=f"文件已上传:{file.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return AttachmentRead(
|
||||
id=attachment.id,
|
||||
filename=attachment.filename,
|
||||
@@ -442,16 +444,17 @@ async def global_delete_attachment(
|
||||
if not can_delete:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
await attachment_crud.soft_delete_attachment(db, attachment)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=attachment.study_id,
|
||||
entity_type=attachment.entity_type,
|
||||
entity_id=attachment.entity_id,
|
||||
action="DELETE_ATTACHMENT",
|
||||
detail=f"文件已删除:{attachment.filename}",
|
||||
operator_id=user.id,
|
||||
operator_role=await get_operator_role_label(db, attachment.study_id, user),
|
||||
)
|
||||
if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=attachment.study_id,
|
||||
entity_type=attachment.entity_type,
|
||||
entity_id=attachment.entity_id,
|
||||
action="DELETE_ATTACHMENT",
|
||||
detail=f"文件已删除:{attachment.filename}",
|
||||
operator_id=user.id,
|
||||
operator_role=await get_operator_role_label(db, attachment.study_id, user),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -494,13 +497,14 @@ async def delete_attachment(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
|
||||
await attachment_crud.soft_delete_attachment(db, attachment)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="DELETE_ATTACHMENT",
|
||||
detail=f"文件已删除:{attachment.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="DELETE_ATTACHMENT",
|
||||
detail=f"文件已删除:{attachment.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -75,12 +75,12 @@ async def acknowledge_notifications(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{distribution_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.post("/{notification_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def mark_notification_read(
|
||||
distribution_id: uuid.UUID,
|
||||
notification_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await desktop_notification_service.mark_notification_read(
|
||||
db, current_user.id, distribution_id
|
||||
db, current_user.id, notification_id
|
||||
)
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.crud import faq_category as category_crud
|
||||
from app.crud import faq_item as item_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
|
||||
from app.utils.pagination import paginate
|
||||
@@ -47,16 +43,6 @@ async def create_category(
|
||||
if dup:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
||||
category = await category_crud.create_category(db, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=payload.study_id,
|
||||
entity_type="faq_category",
|
||||
entity_id=category.id,
|
||||
action="CREATE_FAQ_CATEGORY",
|
||||
detail=json.dumps({"targetName": category.name, "description": f"创建“{category.name}”分类"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
||||
)
|
||||
return CategoryRead.model_validate(category)
|
||||
|
||||
|
||||
@@ -112,16 +98,6 @@ async def update_category(
|
||||
if not target_study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
updated = await category_crud.update_category(db, category, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=updated.study_id,
|
||||
entity_type="faq_category",
|
||||
entity_id=category_id,
|
||||
action="UPDATE_FAQ_CATEGORY",
|
||||
detail=json.dumps({"targetName": updated.name, "description": f"更新“{updated.name}”分类"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, updated.study_id, current_user),
|
||||
)
|
||||
return CategoryRead.model_validate(updated)
|
||||
|
||||
|
||||
@@ -148,16 +124,5 @@ async def delete_category(
|
||||
item_count = await item_crud.count_items_by_category(db, category_id)
|
||||
if item_count > 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
|
||||
category_name = category.name
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=category.study_id,
|
||||
entity_type="faq_category",
|
||||
entity_id=category_id,
|
||||
action="DELETE_FAQ_CATEGORY",
|
||||
detail=json.dumps({"targetName": category_name, "description": f"删除“{category_name}”分类"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, category.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.crud import faq_category as category_crud
|
||||
from app.crud import faq_item as faq_crud
|
||||
from app.crud import faq_reply as reply_crud
|
||||
@@ -31,13 +28,6 @@ def _is_system_admin(current_user) -> bool:
|
||||
return is_system_admin(current_user)
|
||||
|
||||
|
||||
def _compact_text(value: str | None, max_length: int = 40) -> str:
|
||||
text = " ".join(str(value or "").split())
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return f"{text[:max_length]}..."
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=FaqRead,
|
||||
@@ -74,17 +64,6 @@ async def create_faq(
|
||||
reply_in=FaqReplyCreate(content=payload.answer),
|
||||
)
|
||||
await faq_crud.set_status(db, item.id, "PROCESSING")
|
||||
question_name = _compact_text(item.question)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=payload.study_id,
|
||||
entity_type="faq_item",
|
||||
entity_id=item.id,
|
||||
action="CREATE_FAQ_ITEM",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
||||
)
|
||||
return FaqRead.model_validate(item)
|
||||
|
||||
|
||||
@@ -178,19 +157,6 @@ async def update_faq(
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
updated = await faq_crud.update_item(db, item, payload)
|
||||
action = "UPDATE_FAQ_ITEM"
|
||||
question_name = _compact_text(updated.question)
|
||||
detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}”"}, ensure_ascii=False)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_item",
|
||||
entity_id=item_id,
|
||||
action=action,
|
||||
detail=detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
return FaqRead.model_validate(updated)
|
||||
|
||||
|
||||
@@ -337,17 +303,6 @@ async def create_reply(
|
||||
if item.status != "RESOLVED":
|
||||
await faq_crud.set_status(db, item.id, "PROCESSING")
|
||||
await faq_crud.touch_item(db, item.id)
|
||||
question_name = _compact_text(item.question)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_reply",
|
||||
entity_id=reply.id,
|
||||
action="CREATE_FAQ_REPLY",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
data = FaqReplyRead.model_validate(reply)
|
||||
if quote:
|
||||
if quote.is_deleted:
|
||||
@@ -380,20 +335,9 @@ async def delete_faq(
|
||||
item = await faq_crud.get_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
question_name = _compact_text(item.question)
|
||||
await reply_crud.delete_replies_by_faq_id(db, item.id)
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_item",
|
||||
entity_id=item_id,
|
||||
action="DELETE_FAQ_ITEM",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -415,7 +359,6 @@ async def delete_reply(
|
||||
item = await faq_crud.get_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
question_name = _compact_text(item.question)
|
||||
reply = await reply_crud.get_reply(db, reply_id)
|
||||
if not reply or reply.faq_id != item.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
|
||||
@@ -438,13 +381,3 @@ async def delete_reply(
|
||||
resolved_by_confirm=False,
|
||||
)
|
||||
await faq_crud.touch_item(db, item.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_reply",
|
||||
entity_id=reply_id,
|
||||
action="DELETE_FAQ_REPLY",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
@@ -9,7 +8,6 @@ from app.schemas.notification import GeneralNotificationFeed, GeneralNotificatio
|
||||
from app.services import document_service, notification_service, project_reminder_service
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -40,20 +38,25 @@ async def list_notifications(
|
||||
)
|
||||
async def list_general_notifications(
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
category: str | None = None,
|
||||
unread_only: bool = False,
|
||||
requires_action: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> GeneralNotificationFeed:
|
||||
try:
|
||||
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.warning("Failed to synchronize legacy project reminders", exc_info=True)
|
||||
# Fail closed: stale reminders may contain details for a permission that was just revoked.
|
||||
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
|
||||
return await notification_service.list_feed(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=current_user.id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
category=category,
|
||||
unread_only=unread_only,
|
||||
requires_action=requires_action,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -11,9 +10,8 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists
|
||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.crud import attachment as attachment_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import document as document_crud
|
||||
from app.crud import document_version as version_crud
|
||||
from app.models.collaboration import CollaborationRevision
|
||||
@@ -33,29 +31,6 @@ def _content_disposition(filename: str) -> str:
|
||||
return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
|
||||
|
||||
|
||||
async def _log_preview_open(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
resource_type: str,
|
||||
resource_id: uuid.UUID,
|
||||
current_user,
|
||||
) -> None:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="ATTACHMENT" if resource_type == "attachment" else "DOCUMENT_VERSION",
|
||||
entity_id=resource_id,
|
||||
action="OFFICE_PREVIEW_OPEN",
|
||||
detail=json.dumps(
|
||||
{"resource_type": resource_type, "resource_id": str(resource_id), "result": "issued"},
|
||||
ensure_ascii=True,
|
||||
),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attachments/{attachment_id}/config",
|
||||
response_model=OnlyOfficePreviewConfigRead,
|
||||
@@ -98,13 +73,6 @@ async def get_attachment_preview_config(
|
||||
user_id=current_user.id,
|
||||
user_name=current_user.full_name,
|
||||
)
|
||||
await _log_preview_open(
|
||||
db,
|
||||
study_id=attachment.study_id,
|
||||
resource_type="attachment",
|
||||
resource_id=attachment.id,
|
||||
current_user=current_user,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
@@ -151,13 +119,6 @@ async def get_version_preview_config(
|
||||
user_id=current_user.id,
|
||||
user_name=current_user.full_name,
|
||||
)
|
||||
await _log_preview_open(
|
||||
db,
|
||||
study_id=document.trial_id,
|
||||
resource_type="version",
|
||||
resource_id=version.id,
|
||||
current_user=current_user,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||
from app.crud import precaution as precaution_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study as study_crud
|
||||
@@ -29,11 +27,6 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||
|
||||
|
||||
def _precaution_audit_detail(action: str, precaution) -> str:
|
||||
title = str(precaution.title or "").strip() or "注意事项"
|
||||
return json.dumps({"targetName": title, "description": f"{action}注意事项“{title}”"}, ensure_ascii=False)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/precautions",
|
||||
response_model=PrecautionRead,
|
||||
@@ -49,16 +42,6 @@ async def create_precaution(
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_site_name_active(db, study_id, precaution_in.site_name)
|
||||
precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="precaution",
|
||||
entity_id=precaution.id,
|
||||
action="CREATE_PRECAUTION",
|
||||
detail=_precaution_audit_detail("创建", precaution),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return PrecautionRead.model_validate(precaution)
|
||||
|
||||
|
||||
@@ -115,16 +98,6 @@ async def update_precaution(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
||||
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="precaution",
|
||||
entity_id=precaution_id,
|
||||
action="UPDATE_PRECAUTION",
|
||||
detail=_precaution_audit_detail("更新", precaution),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return PrecautionRead.model_validate(precaution)
|
||||
|
||||
|
||||
@@ -144,15 +117,4 @@ async def delete_precaution(
|
||||
if not precaution or precaution.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
||||
precaution_detail = _precaution_audit_detail("删除", precaution)
|
||||
await precaution_crud.delete_precaution(db, precaution)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="precaution",
|
||||
entity_id=precaution_id,
|
||||
action="DELETE_PRECAUTION",
|
||||
detail=precaution_detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.crud import study as study_crud
|
||||
from app.crud import visit as visit_crud
|
||||
from app.schemas.subject import SubjectUpdate
|
||||
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate
|
||||
from app.services import notification_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -42,6 +43,17 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
|
||||
|
||||
|
||||
async def _resolve_visit_reminders(db: AsyncSession, visit_ids: list[uuid.UUID]) -> None:
|
||||
for visit_id in set(visit_ids):
|
||||
await notification_service.resolve_source_notifications(
|
||||
db,
|
||||
source_type="SUBJECT_VISIT_WINDOW",
|
||||
source_id=str(visit_id),
|
||||
)
|
||||
if visit_ids:
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[VisitRead],
|
||||
@@ -145,6 +157,8 @@ async def create_early_termination(
|
||||
if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期")
|
||||
|
||||
existing_visit_ids = [visit.id for visit in await visit_crud.list_visits(db, subject_id)]
|
||||
|
||||
try:
|
||||
visit = await visit_crud.create_early_termination_visit(
|
||||
db,
|
||||
@@ -164,6 +178,7 @@ async def create_early_termination(
|
||||
drop_reason=reason,
|
||||
),
|
||||
)
|
||||
await _resolve_visit_reminders(db, existing_visit_ids)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
@@ -209,6 +224,8 @@ async def update_visit(
|
||||
old_status = visit.status
|
||||
updated = await visit_crud.update_visit(db, visit, visit_in)
|
||||
await subject_crud.sync_subject_status(db, subject)
|
||||
if updated.actual_date is not None or updated.status in {"DONE", "CANCELLED"}:
|
||||
await _resolve_visit_reminders(db, [updated.id])
|
||||
detail = None
|
||||
if visit_in.status:
|
||||
detail = json.dumps(
|
||||
@@ -253,6 +270,7 @@ async def delete_visit(
|
||||
visit_detail = _visit_audit_detail("删除", subject, visit)
|
||||
await visit_crud.delete_visit(db, visit)
|
||||
await subject_crud.sync_subject_status(db, subject)
|
||||
await _resolve_visit_reminders(db, [visit_id])
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
|
||||
@@ -51,6 +51,7 @@ class Settings(BaseSettings):
|
||||
MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800)
|
||||
USER_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650)
|
||||
USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600)
|
||||
NOTIFICATION_SYNC_INTERVAL_SECONDS: int = Field(default=300, ge=60, le=3600)
|
||||
ONLYOFFICE_ENABLED: bool = False
|
||||
ONLYOFFICE_JWT_SECRET: Optional[str] = None
|
||||
ONLYOFFICE_INTERNAL_URL: str = "http://onlyoffice"
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.crud.user import ensure_admin_exists
|
||||
from app.db.base import Base
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.services.visit_scheduler import run_daily_lost_visit_job
|
||||
from app.services.notification_scheduler import run_notification_sync_job
|
||||
from app.services.permission_log_writer import start_log_writer, stop_log_writer
|
||||
from app.services.permission_metric_aggregator import run_hourly_metric_aggregation
|
||||
from app.services.source_location_aggregator import run_hourly_source_location_aggregation
|
||||
@@ -67,6 +68,7 @@ async def lifespan(_: FastAPI):
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with SessionLocal() as session:
|
||||
await ensure_admin_exists(session)
|
||||
notification_scheduler_task = asyncio.create_task(run_notification_sync_job(stop_event))
|
||||
retention_task = asyncio.create_task(run_monitoring_retention(stop_event))
|
||||
yield
|
||||
stop_event.set()
|
||||
@@ -75,6 +77,7 @@ async def lifespan(_: FastAPI):
|
||||
await scheduler_task
|
||||
await aggregator_task
|
||||
await source_location_aggregator_task
|
||||
await notification_scheduler_task
|
||||
await retention_task
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class DesktopNotificationDelivery(Base):
|
||||
__tablename__ = "desktop_notification_deliveries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "distribution_id", name="uq_desktop_notification_user_distribution"),
|
||||
UniqueConstraint("user_id", "notification_id", name="uq_desktop_notification_user_notification"),
|
||||
Index("ix_desktop_notification_claim", "user_id", "delivered_at", "claimed_at"),
|
||||
)
|
||||
|
||||
@@ -35,8 +36,11 @@ class DesktopNotificationDelivery(Base):
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
distribution_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=False
|
||||
distribution_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
notification_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("notifications.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
claim_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -41,6 +41,10 @@ class Notification(Base):
|
||||
source_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
source_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
dedupe_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
requires_action: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="true"
|
||||
)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
@@ -37,17 +37,6 @@ class DesktopNotificationClaimRequest(BaseModel):
|
||||
limit: int = 20
|
||||
|
||||
|
||||
class DesktopNotificationClaimResponse(BaseModel):
|
||||
claim_token: uuid.UUID | None = None
|
||||
lease_expires_at: datetime | None = None
|
||||
items: list[NotificationItem]
|
||||
|
||||
|
||||
class DesktopNotificationAckRequest(BaseModel):
|
||||
claim_token: uuid.UUID
|
||||
delivered_ids: list[uuid.UUID]
|
||||
|
||||
|
||||
class GeneralNotificationRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
@@ -59,12 +48,27 @@ class GeneralNotificationRead(BaseModel):
|
||||
action_path: str | None = None
|
||||
source_type: str
|
||||
source_id: str
|
||||
requires_action: bool = True
|
||||
due_at: datetime | None = None
|
||||
read_at: datetime | None = None
|
||||
resolved_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DesktopNotificationClaimResponse(BaseModel):
|
||||
claim_token: uuid.UUID | None = None
|
||||
lease_expires_at: datetime | None = None
|
||||
items: list[GeneralNotificationRead]
|
||||
|
||||
|
||||
class DesktopNotificationAckRequest(BaseModel):
|
||||
claim_token: uuid.UUID
|
||||
delivered_ids: list[uuid.UUID]
|
||||
|
||||
|
||||
class GeneralNotificationFeed(BaseModel):
|
||||
unread_count: int
|
||||
total_count: int
|
||||
items: list[GeneralNotificationRead]
|
||||
|
||||
@@ -16,9 +16,8 @@ from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_operator_role_label, is_system_admin
|
||||
from app.core.deps import is_system_admin
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.models.collaboration import (
|
||||
CollaborationEditRequest,
|
||||
@@ -427,23 +426,6 @@ async def require_ownership_transfer(db: AsyncSession, item: CollaborationFile,
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您没有转让此协作文件所有权的权限")
|
||||
|
||||
|
||||
async def _audit(db: AsyncSession, item: CollaborationFile, action: str, user, extra: dict | None = None) -> None:
|
||||
detail = {"file_id": str(item.id), "title": item.title}
|
||||
if extra:
|
||||
detail.update(extra)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="COLLABORATION_FILE",
|
||||
entity_id=item.id,
|
||||
action=action,
|
||||
detail=json.dumps(detail, ensure_ascii=False),
|
||||
operator_id=user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, user),
|
||||
auto_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def create_folder(db: AsyncSession, study_id: uuid.UUID, payload: CollaborationFolderCreate, user) -> CollaborationFolder:
|
||||
if payload.parent_id:
|
||||
await _folder_or_404(db, study_id, payload.parent_id)
|
||||
@@ -618,7 +600,6 @@ async def _create_file_from_bytes(
|
||||
content: bytes,
|
||||
source: str,
|
||||
user,
|
||||
source_file_id: uuid.UUID | None = None,
|
||||
) -> CollaborationFile:
|
||||
membership = await member_crud.get_member(db, study_id, user.id)
|
||||
await _require_role_assignable(db, study_id, user, membership, "MANAGER")
|
||||
@@ -637,10 +618,6 @@ async def _create_file_from_bytes(
|
||||
await db.flush()
|
||||
db.add(CollaborationMember(file_id=item.id, user_id=user.id, role="MANAGER", invited_by=user.id))
|
||||
await _persist_revision_bytes(db, item, content, source=source, created_by=user.id, original_filename=item.title)
|
||||
audit_detail = {"source": source}
|
||||
if source_file_id:
|
||||
audit_detail["source_file_id"] = str(source_file_id)
|
||||
await _audit(db, item, "COLLABORATION_FILE_CREATED", user, audit_detail)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
@@ -775,11 +752,6 @@ async def update_file(
|
||||
db: AsyncSession, item: CollaborationFile, payload: CollaborationFileUpdate, user
|
||||
) -> CollaborationFile:
|
||||
await require_file_manager(db, item, user)
|
||||
previous_title = item.title
|
||||
previous_folder_id = item.folder_id
|
||||
previous_allow_export = item.allow_export
|
||||
previous_allow_edit_request = item.allow_edit_request
|
||||
previous_allow_sheet_structure_edit = item.allow_sheet_structure_edit
|
||||
values = payload.model_dump(exclude_unset=True)
|
||||
if "folder_id" in values and values["folder_id"]:
|
||||
await _folder_or_404(db, item.study_id, values["folder_id"])
|
||||
@@ -810,25 +782,6 @@ async def update_file(
|
||||
item.generation += 1
|
||||
for key, value in values.items():
|
||||
setattr(item, key, value)
|
||||
title_changed = item.title != previous_title
|
||||
folder_changed = item.folder_id != previous_folder_id
|
||||
if title_changed and not folder_changed:
|
||||
action = "COLLABORATION_FILE_RENAMED"
|
||||
elif folder_changed and not title_changed:
|
||||
action = "COLLABORATION_FILE_MOVED"
|
||||
else:
|
||||
action = "COLLABORATION_FILE_UPDATED"
|
||||
await _audit(db, item, action, user, {
|
||||
"previous_title": previous_title,
|
||||
"previous_folder_id": str(previous_folder_id) if previous_folder_id else None,
|
||||
"folder_id": str(item.folder_id) if item.folder_id else None,
|
||||
"previous_allow_export": previous_allow_export,
|
||||
"allow_export": item.allow_export,
|
||||
"previous_allow_edit_request": previous_allow_edit_request,
|
||||
"allow_edit_request": item.allow_edit_request,
|
||||
"previous_allow_sheet_structure_edit": previous_allow_sheet_structure_edit,
|
||||
"allow_sheet_structure_edit": item.allow_sheet_structure_edit,
|
||||
})
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
@@ -874,7 +827,6 @@ async def copy_file(db: AsyncSession, item: CollaborationFile, user) -> Collabor
|
||||
content=content,
|
||||
source="COPY",
|
||||
user=user,
|
||||
source_file_id=item.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -885,14 +837,6 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可下载版本")
|
||||
if not Path(revision.file_uri).is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件当前版本不存在")
|
||||
await _audit(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_FILE_DOWNLOADED",
|
||||
user,
|
||||
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
|
||||
)
|
||||
await db.commit()
|
||||
return revision
|
||||
|
||||
|
||||
@@ -900,7 +844,6 @@ async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None
|
||||
await require_file_manager(db, item, user)
|
||||
item.status = "DELETED"
|
||||
item.deleted_at = datetime.now(timezone.utc)
|
||||
await _audit(db, item, "COLLABORATION_FILE_TRASHED", user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -908,7 +851,6 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
|
||||
await require_file_manager(db, item, user)
|
||||
item.status = "ACTIVE"
|
||||
item.deleted_at = None
|
||||
await _audit(db, item, "COLLABORATION_FILE_RESTORED", user)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
@@ -916,14 +858,10 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
|
||||
|
||||
async def record_export(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
|
||||
await require_file_exporter(db, item, user)
|
||||
await _audit(db, item, "COLLABORATION_FILE_EXPORTED", user, {"file_type": file_type})
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def record_download(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
|
||||
await require_file_exporter(db, item, user)
|
||||
await _audit(db, item, "COLLABORATION_FILE_DOWNLOADED", user, {"file_type": file_type})
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_revisions(db: AsyncSession, item: CollaborationFile) -> list[CollaborationRevisionRead]:
|
||||
@@ -967,10 +905,6 @@ async def delete_revision(
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前版本不能删除")
|
||||
revision.deleted_at = datetime.now(timezone.utc)
|
||||
revision.deleted_by = user.id
|
||||
await _audit(db, item, "COLLABORATION_REVISION_DELETED", user, {
|
||||
"revision_id": str(revision.id),
|
||||
"revision_no": revision.revision_no,
|
||||
})
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -984,13 +918,6 @@ async def update_revision(
|
||||
await require_file_editor(db, item, user)
|
||||
revision = await get_revision_or_404(db, item, revision_id)
|
||||
revision.change_summary = payload.change_summary
|
||||
await _audit(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_REVISION_NAMED",
|
||||
user,
|
||||
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(revision)
|
||||
return revision
|
||||
@@ -1002,14 +929,6 @@ async def prepare_revision_preview(
|
||||
revision = await get_revision_or_404(db, item, revision_id)
|
||||
if not Path(revision.file_uri).is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作修订内容不存在")
|
||||
await _audit(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_REVISION_PREVIEWED",
|
||||
user,
|
||||
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
|
||||
)
|
||||
await db.commit()
|
||||
return revision
|
||||
|
||||
|
||||
@@ -1040,7 +959,6 @@ async def copy_revision(
|
||||
content=content,
|
||||
source="COPY",
|
||||
user=user,
|
||||
source_file_id=item.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1063,7 +981,6 @@ async def restore_revision(
|
||||
locked = await db.get(CollaborationFile, item.id)
|
||||
if locked:
|
||||
locked.generation += 1
|
||||
await _audit(db, item, "COLLABORATION_REVISION_RESTORED", user, {"revision_no": revision.revision_no})
|
||||
await db.commit()
|
||||
await db.refresh(restored)
|
||||
return restored
|
||||
@@ -1111,7 +1028,6 @@ async def upsert_member(
|
||||
else:
|
||||
member = CollaborationMember(file_id=item.id, user_id=payload.user_id, role=payload.role, invited_by=user.id)
|
||||
db.add(member)
|
||||
await _audit(db, item, "COLLABORATION_MEMBER_UPSERTED", user, {"member_id": str(payload.user_id), "role": payload.role})
|
||||
await db.commit()
|
||||
await db.refresh(member)
|
||||
return CollaborationMemberRead(
|
||||
@@ -1132,7 +1048,6 @@ async def remove_member(db: AsyncSession, item: CollaborationFile, user_id: uuid
|
||||
if not member:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作成员不存在")
|
||||
await db.delete(member)
|
||||
await _audit(db, item, "COLLABORATION_MEMBER_REMOVED", user, {"member_id": str(user_id)})
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1192,7 +1107,6 @@ async def create_edit_request(
|
||||
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
|
||||
db.add(request)
|
||||
await db.flush()
|
||||
await _audit(db, item, "COLLABORATION_EDIT_REQUESTED", user, {"request_id": str(request.id)})
|
||||
manager_ids = set((await db.scalars(
|
||||
select(StudyMember.user_id)
|
||||
.outerjoin(
|
||||
@@ -1225,6 +1139,7 @@ async def create_edit_request(
|
||||
source_type="COLLABORATION_EDIT_REQUEST",
|
||||
source_id=str(request.id),
|
||||
dedupe_key=f"collaboration-edit-request:{request.id}",
|
||||
source_version="PENDING",
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(request)
|
||||
@@ -1286,16 +1201,30 @@ async def resolve_edit_request(
|
||||
request.status = payload.status
|
||||
request.resolved_by = user.id
|
||||
request.resolved_at = datetime.now(timezone.utc)
|
||||
approved = payload.status == "APPROVED"
|
||||
await notification_service.create_recipient_notifications(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
recipient_ids=[request.requester_id],
|
||||
category="COLLABORATION_EDIT_REQUEST_RESULT",
|
||||
priority="NORMAL",
|
||||
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
|
||||
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
|
||||
action_path=(
|
||||
f"/knowledge/collaboration/{item.id}"
|
||||
if approved
|
||||
else "/knowledge/collaboration"
|
||||
),
|
||||
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
|
||||
source_id=str(request.id),
|
||||
dedupe_key=f"collaboration-edit-request-result:{request.id}",
|
||||
requires_action=False,
|
||||
)
|
||||
await notification_service.resolve_source_notifications(
|
||||
db,
|
||||
source_type="COLLABORATION_EDIT_REQUEST",
|
||||
source_id=str(request.id),
|
||||
)
|
||||
await _audit(db, item, "COLLABORATION_EDIT_REQUEST_RESOLVED", user, {
|
||||
"request_id": str(request.id),
|
||||
"requester_id": str(request.requester_id),
|
||||
"status": payload.status,
|
||||
})
|
||||
await db.commit()
|
||||
await db.refresh(request)
|
||||
return _edit_request_read(request, requester)
|
||||
@@ -1341,10 +1270,6 @@ async def transfer_ownership(
|
||||
previous_owner_member.role = "MANAGER"
|
||||
locked.owner_id = payload.new_owner_id
|
||||
locked.updated_at = datetime.now(timezone.utc)
|
||||
await _audit(db, locked, "COLLABORATION_OWNERSHIP_TRANSFERRED", user, {
|
||||
"previous_owner_id": str(previous_owner_id),
|
||||
"new_owner_id": str(payload.new_owner_id),
|
||||
})
|
||||
await db.commit()
|
||||
await db.refresh(locked)
|
||||
return locked
|
||||
|
||||
@@ -177,19 +177,6 @@ async def update_share_link(
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
await collaboration_service._audit(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_SHARE_LINK_UPDATED",
|
||||
user,
|
||||
{
|
||||
"enabled": link.enabled,
|
||||
"access_mode": link.access_mode,
|
||||
"file_allow_export": item.allow_export,
|
||||
"expiry_policy": link.expiry_policy,
|
||||
"has_password": bool(link.password_hash),
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
return share_link_read(link)
|
||||
|
||||
@@ -10,12 +10,9 @@ from app.models.desktop_notification import (
|
||||
DesktopNotificationDelivery,
|
||||
DesktopNotificationSubscription,
|
||||
)
|
||||
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||
from app.models.document import Document
|
||||
from app.models.document_version import DocumentVersion
|
||||
from app.models.study import Study
|
||||
from app.models.notification import Notification
|
||||
from app.models.study_member import StudyMember
|
||||
from app.schemas.notification import NotificationItem
|
||||
from app.models.user import User
|
||||
|
||||
CLAIM_LEASE = timedelta(minutes=5)
|
||||
|
||||
@@ -47,46 +44,28 @@ async def set_subscription(
|
||||
|
||||
|
||||
def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime):
|
||||
role_target_exists = exists(
|
||||
active_membership = exists(
|
||||
select(StudyMember.id).where(
|
||||
StudyMember.study_id == Document.trial_id,
|
||||
StudyMember.study_id == Notification.study_id,
|
||||
StudyMember.user_id == user_id,
|
||||
StudyMember.is_active.is_(True),
|
||||
StudyMember.role_in_study == Distribution.target_id,
|
||||
)
|
||||
)
|
||||
target_matches = or_(
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.USER,
|
||||
Distribution.target_id == str(user_id),
|
||||
),
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.ROLE,
|
||||
role_target_exists,
|
||||
),
|
||||
)
|
||||
return (
|
||||
select(
|
||||
Distribution,
|
||||
Document,
|
||||
DocumentVersion,
|
||||
Study,
|
||||
DesktopNotificationDelivery,
|
||||
)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.join(Study, Document.trial_id == Study.id)
|
||||
select(Notification, DesktopNotificationDelivery)
|
||||
.outerjoin(
|
||||
DesktopNotificationDelivery,
|
||||
and_(
|
||||
DesktopNotificationDelivery.distribution_id == Distribution.id,
|
||||
DesktopNotificationDelivery.notification_id == Notification.id,
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Distribution.status == DistributionStatus.ACTIVE,
|
||||
Distribution.created_at >= enabled_at,
|
||||
target_matches,
|
||||
Notification.recipient_id == user_id,
|
||||
Notification.created_at >= enabled_at,
|
||||
Notification.resolved_at.is_(None),
|
||||
Notification.read_at.is_(None),
|
||||
active_membership,
|
||||
or_(
|
||||
DesktopNotificationDelivery.id.is_(None),
|
||||
and_(
|
||||
@@ -98,8 +77,8 @@ def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: date
|
||||
),
|
||||
),
|
||||
)
|
||||
.order_by(Distribution.created_at.asc())
|
||||
.with_for_update(of=Distribution, skip_locked=True)
|
||||
.order_by(Notification.created_at.asc())
|
||||
.with_for_update(of=Notification, skip_locked=True)
|
||||
)
|
||||
|
||||
|
||||
@@ -107,45 +86,46 @@ async def claim_notifications(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> tuple[uuid.UUID | None, datetime | None, list[NotificationItem]]:
|
||||
) -> tuple[uuid.UUID | None, datetime | None, list[Notification]]:
|
||||
subscription = await get_subscription(db, user_id)
|
||||
if not subscription or not subscription.enabled or not subscription.enabled_at:
|
||||
return None, None, []
|
||||
|
||||
# Reconcile every active project before selecting desktop deliveries so permission
|
||||
# changes fail closed and time-based reminders do not depend on opening the web Feed.
|
||||
user = await db.get(User, user_id)
|
||||
if user is None or not user.is_active:
|
||||
return None, None, []
|
||||
study_ids = (await db.scalars(
|
||||
select(StudyMember.study_id).where(
|
||||
StudyMember.user_id == user_id,
|
||||
StudyMember.is_active.is_(True),
|
||||
)
|
||||
)).all()
|
||||
from app.services.project_reminder_service import sync_project_reminders
|
||||
|
||||
for study_id in set(study_ids):
|
||||
await sync_project_reminders(db, study_id, user)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = uuid.uuid4()
|
||||
rows = (
|
||||
await db.execute(
|
||||
_eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE).limit(max(1, min(limit, 50)))
|
||||
_eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE)
|
||||
.limit(max(1, min(limit, 50)))
|
||||
)
|
||||
).all()
|
||||
items: list[NotificationItem] = []
|
||||
for distribution, document, version, study, delivery in rows:
|
||||
items: list[Notification] = []
|
||||
for notification, delivery in rows:
|
||||
if delivery is None:
|
||||
delivery = DesktopNotificationDelivery(
|
||||
user_id=user_id,
|
||||
distribution_id=distribution.id,
|
||||
notification_id=notification.id,
|
||||
)
|
||||
db.add(delivery)
|
||||
delivery.claim_token = token
|
||||
delivery.claimed_at = now
|
||||
items.append(
|
||||
NotificationItem(
|
||||
id=distribution.id,
|
||||
document_id=document.id,
|
||||
version_id=version.id,
|
||||
document_title=document.title,
|
||||
document_no=document.doc_no,
|
||||
version_no=version.version_no,
|
||||
change_summary=version.change_summary,
|
||||
effective_at=version.effective_at,
|
||||
created_at=distribution.created_at,
|
||||
study_id=study.id,
|
||||
study_name=study.name,
|
||||
delivered_at=delivery.delivered_at,
|
||||
read_at=delivery.read_at,
|
||||
)
|
||||
)
|
||||
items.append(notification)
|
||||
await db.commit()
|
||||
if not items:
|
||||
return None, None, []
|
||||
@@ -164,7 +144,7 @@ async def acknowledge_notifications(
|
||||
.where(
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
DesktopNotificationDelivery.claim_token == claim_token,
|
||||
DesktopNotificationDelivery.distribution_id.in_(delivered_ids),
|
||||
DesktopNotificationDelivery.notification_id.in_(delivered_ids),
|
||||
)
|
||||
.values(delivered_at=datetime.now(timezone.utc))
|
||||
)
|
||||
@@ -174,21 +154,17 @@ async def acknowledge_notifications(
|
||||
async def mark_notification_read(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
distribution_id: uuid.UUID,
|
||||
notification_id: uuid.UUID,
|
||||
) -> None:
|
||||
delivery = (
|
||||
await db.execute(
|
||||
select(DesktopNotificationDelivery).where(
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
DesktopNotificationDelivery.distribution_id == distribution_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if delivery is None:
|
||||
delivery = DesktopNotificationDelivery(
|
||||
user_id=user_id,
|
||||
distribution_id=distribution_id,
|
||||
)
|
||||
db.add(delivery)
|
||||
delivery.read_at = datetime.now(timezone.utc)
|
||||
item = await db.scalar(select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.recipient_id == user_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
))
|
||||
if item is None:
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
item.read_at = item.read_at or now
|
||||
if not item.requires_action:
|
||||
item.resolved_at = item.resolved_at or now
|
||||
await db.commit()
|
||||
|
||||
@@ -11,7 +11,7 @@ from urllib.parse import quote
|
||||
import aiofiles
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy import String, and_, cast, delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope
|
||||
@@ -32,6 +32,7 @@ from app.models.distribution import Distribution, DistributionStatus, Distributi
|
||||
from app.models.document import Document, DocumentScopeType, DocumentStatus
|
||||
from app.models.document_version import DocumentVersion, DocumentVersionStatus
|
||||
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||
from app.models.notification import Notification
|
||||
from app.models.study import Study
|
||||
from app.schemas.acknowledgement import AcknowledgementCreate
|
||||
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
|
||||
@@ -39,6 +40,7 @@ from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
|
||||
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
|
||||
from app.schemas.notification import NotificationItem
|
||||
from app.schemas.user import UserDisplay
|
||||
from app.services import notification_service
|
||||
|
||||
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents"
|
||||
|
||||
@@ -688,6 +690,10 @@ async def create_acknowledgement(
|
||||
if distribution.target_type == DistributionTargetType.ROLE:
|
||||
if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
|
||||
if distribution.target_type == DistributionTargetType.SITE and not is_system_admin(current_user):
|
||||
site_ids = await site_crud.list_ids_by_contact_user(db, doc.trial_id, current_user.id)
|
||||
if distribution.target_id not in {str(site_id) for site_id in site_ids}:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发中心范围内")
|
||||
|
||||
if payload.ack_type != AcknowledgementType.RECEIVED:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执")
|
||||
@@ -722,6 +728,12 @@ async def create_acknowledgement(
|
||||
operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
|
||||
)
|
||||
)
|
||||
await notification_service.resolve_source_notifications(
|
||||
db,
|
||||
source_type="DOCUMENT_DISTRIBUTION",
|
||||
source_id=str(distribution.id),
|
||||
recipient_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(ack)
|
||||
return ack
|
||||
@@ -812,10 +824,18 @@ async def list_distribution_notifications(
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(Study, Document.trial_id == Study.id)
|
||||
.outerjoin(
|
||||
Notification,
|
||||
and_(
|
||||
Notification.source_type == "DOCUMENT_DISTRIBUTION",
|
||||
Notification.source_id == cast(Distribution.id, String),
|
||||
Notification.recipient_id == current_user.id,
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
DesktopNotificationDelivery,
|
||||
and_(
|
||||
DesktopNotificationDelivery.distribution_id == Distribution.id,
|
||||
DesktopNotificationDelivery.notification_id == Notification.id,
|
||||
DesktopNotificationDelivery.user_id == current_user.id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Server-side materialization for time and state driven business reminders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.study_member import StudyMember
|
||||
from app.models.user import User, UserStatus
|
||||
from app.services.project_reminder_service import sync_project_reminders
|
||||
|
||||
logger = logging.getLogger("ctms.notifications")
|
||||
NOTIFICATION_SYNC_LOCK_KEY = 0x43544D53 # "CTMS"
|
||||
|
||||
|
||||
async def sync_all_project_reminders_once() -> int:
|
||||
"""Synchronize every active project member once, with a cross-worker advisory lock."""
|
||||
async with SessionLocal() as lock_session:
|
||||
acquired = bool(await lock_session.scalar(
|
||||
text("SELECT pg_try_advisory_lock(:lock_key)"),
|
||||
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
|
||||
))
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
memberships = (await lock_session.execute(
|
||||
select(StudyMember.study_id, StudyMember.user_id)
|
||||
.join(User, User.id == StudyMember.user_id)
|
||||
.where(
|
||||
StudyMember.is_active.is_(True),
|
||||
User.status == UserStatus.ACTIVE,
|
||||
)
|
||||
)).all()
|
||||
await lock_session.commit()
|
||||
synced = 0
|
||||
for study_id, user_id in memberships:
|
||||
async with SessionLocal() as session:
|
||||
try:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None or not user.is_active:
|
||||
continue
|
||||
await sync_project_reminders(session, study_id, user)
|
||||
synced += 1
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception(
|
||||
"Failed to synchronize reminders for study=%s user=%s",
|
||||
study_id,
|
||||
user_id,
|
||||
)
|
||||
return synced
|
||||
finally:
|
||||
await lock_session.execute(
|
||||
text("SELECT pg_advisory_unlock(:lock_key)"),
|
||||
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
|
||||
)
|
||||
|
||||
|
||||
async def run_notification_sync_job(stop_event: asyncio.Event) -> None:
|
||||
logger.info("Notification synchronization task started")
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
synced = await sync_all_project_reminders_once()
|
||||
logger.debug("Synchronized reminders for %s project memberships", synced)
|
||||
except Exception:
|
||||
logger.exception("Notification synchronization task failed")
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
stop_event.wait(),
|
||||
timeout=settings.NOTIFICATION_SYNC_INTERVAL_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
logger.info("Notification synchronization task stopped")
|
||||
@@ -8,10 +8,24 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||
from app.models.notification import Notification
|
||||
from app.schemas.notification import GeneralNotificationFeed
|
||||
|
||||
|
||||
async def _requeue_desktop_delivery(db: AsyncSession, notification_id: uuid.UUID) -> None:
|
||||
"""Allow an escalated/reopened reminder to be delivered again by the desktop channel."""
|
||||
await db.execute(
|
||||
update(DesktopNotificationDelivery)
|
||||
.where(DesktopNotificationDelivery.notification_id == notification_id)
|
||||
.values(
|
||||
claim_token=None,
|
||||
claimed_at=None,
|
||||
delivered_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def create_recipient_notifications(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -25,6 +39,9 @@ async def create_recipient_notifications(
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
dedupe_key: str,
|
||||
source_version: str | None = None,
|
||||
requires_action: bool = True,
|
||||
due_at: datetime | None = None,
|
||||
) -> None:
|
||||
recipients = set(recipient_ids)
|
||||
if not recipients:
|
||||
@@ -46,10 +63,78 @@ async def create_recipient_notifications(
|
||||
action_path=action_path,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_version=source_version,
|
||||
dedupe_key=dedupe_key,
|
||||
requires_action=requires_action,
|
||||
due_at=due_at,
|
||||
))
|
||||
|
||||
|
||||
async def sync_state_notification(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
category: str,
|
||||
priority: str,
|
||||
title: str,
|
||||
message: str,
|
||||
action_path: str,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
source_version: str,
|
||||
active: bool,
|
||||
due_at: datetime | None = None,
|
||||
requires_action: bool = True,
|
||||
dedupe_key: str | None = None,
|
||||
) -> None:
|
||||
"""Synchronize one actionable business state into a recipient reminder."""
|
||||
dedupe_key = dedupe_key or f"state:{source_type}:{source_id}"
|
||||
item = await db.scalar(select(Notification).where(
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.dedupe_key == dedupe_key,
|
||||
))
|
||||
now = datetime.now(timezone.utc)
|
||||
if not active:
|
||||
if item and item.resolved_at is None:
|
||||
item.resolved_at = now
|
||||
item.read_at = item.read_at or now
|
||||
return
|
||||
if item is None:
|
||||
db.add(Notification(
|
||||
study_id=study_id,
|
||||
recipient_id=recipient_id,
|
||||
category=category,
|
||||
priority=priority,
|
||||
title=title,
|
||||
message=message,
|
||||
action_path=action_path,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_version=source_version,
|
||||
dedupe_key=dedupe_key,
|
||||
requires_action=requires_action,
|
||||
due_at=due_at,
|
||||
))
|
||||
return
|
||||
|
||||
should_notify_again = item.resolved_at is not None or item.source_version != source_version
|
||||
item.study_id = study_id
|
||||
item.category = category
|
||||
item.priority = priority
|
||||
item.title = title
|
||||
item.message = message
|
||||
item.action_path = action_path
|
||||
item.source_version = source_version
|
||||
item.requires_action = requires_action
|
||||
item.due_at = due_at
|
||||
if should_notify_again:
|
||||
item.read_at = None
|
||||
item.created_at = now
|
||||
await _requeue_desktop_delivery(db, item.id)
|
||||
item.resolved_at = None
|
||||
|
||||
|
||||
async def sync_aggregate_notification(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -63,6 +148,7 @@ async def sync_aggregate_notification(
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
count: int,
|
||||
due_at: datetime | None = None,
|
||||
) -> None:
|
||||
dedupe_key = f"aggregate:{source_type}:{source_id}"
|
||||
item = await db.scalar(select(Notification).where(
|
||||
@@ -89,32 +175,83 @@ async def sync_aggregate_notification(
|
||||
source_id=source_id,
|
||||
source_version=version,
|
||||
dedupe_key=dedupe_key,
|
||||
requires_action=True,
|
||||
due_at=due_at,
|
||||
))
|
||||
return
|
||||
previous_count = int(item.source_version or 0)
|
||||
item.category = category
|
||||
item.priority = priority
|
||||
item.title = title
|
||||
item.message = message
|
||||
item.action_path = action_path
|
||||
item.source_version = version
|
||||
item.due_at = due_at
|
||||
if item.resolved_at is not None or count > previous_count:
|
||||
item.read_at = None
|
||||
item.created_at = now
|
||||
await _requeue_desktop_delivery(db, item.id)
|
||||
item.resolved_at = None
|
||||
|
||||
|
||||
async def resolve_missing_recipient_sources(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
source_type: str,
|
||||
active_source_ids: set[str],
|
||||
) -> None:
|
||||
filters = [
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.source_type == source_type,
|
||||
Notification.resolved_at.is_(None),
|
||||
]
|
||||
if active_source_ids:
|
||||
filters.append(Notification.source_id.not_in(active_source_ids))
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(*filters)
|
||||
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
|
||||
)
|
||||
|
||||
|
||||
async def resolve_source_notifications(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
recipient_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
filters = [
|
||||
Notification.source_type == source_type,
|
||||
Notification.source_id == source_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
]
|
||||
if recipient_id is not None:
|
||||
filters.append(Notification.recipient_id == recipient_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(*filters)
|
||||
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
|
||||
)
|
||||
|
||||
|
||||
async def resolve_recipient_study_notifications(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.source_type == source_type,
|
||||
Notification.source_id == source_id,
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
)
|
||||
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
|
||||
@@ -126,7 +263,11 @@ async def list_feed(
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
category: str | None = None,
|
||||
unread_only: bool = False,
|
||||
requires_action: bool | None = None,
|
||||
) -> GeneralNotificationFeed:
|
||||
active_filter = (
|
||||
Notification.study_id == study_id,
|
||||
@@ -136,13 +277,36 @@ async def list_feed(
|
||||
unread_count = int(await db.scalar(
|
||||
select(func.count(Notification.id)).where(*active_filter, Notification.read_at.is_(None))
|
||||
) or 0)
|
||||
|
||||
filters = [
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
]
|
||||
if category:
|
||||
filters.append(Notification.category == category)
|
||||
if unread_only:
|
||||
filters.append(Notification.read_at.is_(None))
|
||||
if requires_action is not None:
|
||||
filters.append(Notification.requires_action.is_(requires_action))
|
||||
total_count = int(await db.scalar(
|
||||
select(func.count(Notification.id)).where(*filters)
|
||||
) or 0)
|
||||
items = (await db.scalars(
|
||||
select(Notification)
|
||||
.where(*active_filter)
|
||||
.order_by(Notification.read_at.is_not(None), Notification.created_at.desc())
|
||||
.limit(max(1, min(limit, 50)))
|
||||
.where(*filters)
|
||||
.order_by(
|
||||
Notification.read_at.is_not(None),
|
||||
Notification.created_at.desc(),
|
||||
)
|
||||
.offset(max(0, skip))
|
||||
.limit(max(1, min(limit, 100)))
|
||||
)).all()
|
||||
return GeneralNotificationFeed(unread_count=unread_count, items=list(items))
|
||||
return GeneralNotificationFeed(
|
||||
unread_count=unread_count,
|
||||
total_count=total_count,
|
||||
items=list(items),
|
||||
)
|
||||
|
||||
|
||||
async def mark_read(
|
||||
@@ -161,7 +325,10 @@ async def mark_read(
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
|
||||
if item.read_at is None:
|
||||
item.read_at = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
item.read_at = now
|
||||
if not item.requires_action:
|
||||
item.resolved_at = now
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
@@ -173,6 +340,7 @@ async def mark_all_read(
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(
|
||||
@@ -180,7 +348,19 @@ async def mark_all_read(
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
Notification.read_at.is_(None),
|
||||
Notification.requires_action.is_(False),
|
||||
)
|
||||
.values(read_at=datetime.now(timezone.utc))
|
||||
.values(read_at=now, resolved_at=now)
|
||||
)
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
Notification.read_at.is_(None),
|
||||
Notification.requires_action.is_(True),
|
||||
)
|
||||
.values(read_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -396,11 +396,6 @@ async def process_callback(
|
||||
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document
|
||||
# Server 缓存重建时仍从最近一次持久化内容恢复。
|
||||
session.base_revision_id = revision.id
|
||||
if created and actor:
|
||||
await collaboration_service._audit(
|
||||
db, item, "COLLABORATION_REVISION_SAVED", actor,
|
||||
{"revision_no": revision.revision_no, "source": source},
|
||||
)
|
||||
elif payload.status == 4:
|
||||
session.status = "CLOSED"
|
||||
session.closed_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -1,61 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, is_system_admin
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import ae as ae_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import monitoring_visit_issue as monitoring_issue_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.models.acknowledgement import Acknowledgement, AcknowledgementType
|
||||
from app.models.ae import AdverseEvent
|
||||
from app.models.collaboration import CollaborationEditRequest, CollaborationFile, CollaborationMember
|
||||
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||
from app.models.document import Document, DocumentStatus
|
||||
from app.models.document_version import DocumentVersion
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.monitoring_visit_issue import MonitoringVisitIssue
|
||||
from app.models.site import Site
|
||||
from app.models.subject import Subject
|
||||
from app.models.user import User
|
||||
from app.models.visit import Visit
|
||||
from app.services import notification_service
|
||||
|
||||
BUSINESS_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
RISK_DUE_SOON_DAYS = 3
|
||||
MILESTONE_DUE_SOON_DAYS = 7
|
||||
VISIT_WINDOW_DUE_SOON_DAYS = 3
|
||||
|
||||
async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) -> None:
|
||||
membership = await member_crud.get_member(db, study_id, user.id)
|
||||
if not is_system_admin(user) and (not membership or not membership.is_active):
|
||||
return
|
||||
role = membership.role_in_study if membership else ""
|
||||
|
||||
def _date_due_at(value: date | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
return datetime.combine(value, time.max, tzinfo=BUSINESS_TIMEZONE).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _as_utc(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
async def _count_and_earliest(db: AsyncSession, model, *filters):
|
||||
return (await db.execute(
|
||||
select(func.count(model.id), func.min(model.report_due_date if model is AdverseEvent else model.due_at))
|
||||
.where(*filters)
|
||||
)).one()
|
||||
|
||||
|
||||
async def _sync_risk_reminders(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
user,
|
||||
role: str,
|
||||
current_time: datetime,
|
||||
) -> None:
|
||||
can_read_aes = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "subject_aes:read"
|
||||
)
|
||||
can_read_monitoring = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "monitoring_issues:read"
|
||||
)
|
||||
cra_scope = await get_cra_site_scope(db, study_id, user)
|
||||
site_ids = cra_scope[0] if cra_scope else None
|
||||
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
|
||||
due_soon_date = today + timedelta(days=RISK_DUE_SOON_DAYS)
|
||||
|
||||
ae_scope = [AdverseEvent.study_id == study_id, AdverseEvent.status != "CLOSED"]
|
||||
if site_ids is not None:
|
||||
ae_scope.append(AdverseEvent.site_id.in_(site_ids))
|
||||
overdue_aes = 0
|
||||
overdue_ae_due = None
|
||||
due_soon_aes = 0
|
||||
due_soon_ae_due = None
|
||||
if can_read_aes:
|
||||
cra_scope = await get_cra_site_scope(db, study_id, user)
|
||||
items = await ae_crud.list_ae(
|
||||
overdue_aes, overdue_ae_due = await _count_and_earliest(
|
||||
db,
|
||||
study_id,
|
||||
overdue=True,
|
||||
site_ids=cra_scope[0] if cra_scope else None,
|
||||
AdverseEvent,
|
||||
*ae_scope,
|
||||
AdverseEvent.report_due_date.is_not(None),
|
||||
AdverseEvent.report_due_date < today,
|
||||
)
|
||||
due_soon_aes, due_soon_ae_due = await _count_and_earliest(
|
||||
db,
|
||||
AdverseEvent,
|
||||
*ae_scope,
|
||||
AdverseEvent.report_due_date >= today,
|
||||
AdverseEvent.report_due_date <= due_soon_date,
|
||||
)
|
||||
overdue_aes = len(items)
|
||||
await notification_service.sync_aggregate_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category="RISK_OVERDUE_AE",
|
||||
priority="HIGH",
|
||||
priority="URGENT",
|
||||
title="逾期 AE 待处理",
|
||||
message=f"当前有 {overdue_aes} 条逾期 AE 需要跟进",
|
||||
action_path="/risk-issues/sae",
|
||||
source_type="RISK_OVERDUE_AE",
|
||||
source_id=str(study_id),
|
||||
count=overdue_aes if can_read_aes else 0,
|
||||
count=int(overdue_aes) if can_read_aes else 0,
|
||||
due_at=_date_due_at(overdue_ae_due),
|
||||
)
|
||||
await notification_service.sync_aggregate_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category="RISK_AE_DUE_SOON",
|
||||
priority="HIGH",
|
||||
title="AE 上报时限临近",
|
||||
message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_aes} 条 AE 到达上报时限",
|
||||
action_path="/risk-issues/sae",
|
||||
source_type="RISK_AE_DUE_SOON",
|
||||
source_id=str(study_id),
|
||||
count=int(due_soon_aes) if can_read_aes else 0,
|
||||
due_at=_date_due_at(due_soon_ae_due),
|
||||
)
|
||||
|
||||
monitoring_scope = [
|
||||
MonitoringVisitIssue.study_id == study_id,
|
||||
MonitoringVisitIssue.status == "OPEN",
|
||||
MonitoringVisitIssue.due_at.is_not(None),
|
||||
]
|
||||
if site_ids is not None:
|
||||
monitoring_scope.append(MonitoringVisitIssue.site_id.in_(site_ids))
|
||||
overdue_monitoring = 0
|
||||
overdue_monitoring_due = None
|
||||
due_soon_monitoring = 0
|
||||
due_soon_monitoring_due = None
|
||||
if can_read_monitoring:
|
||||
overdue_monitoring = len(await monitoring_issue_crud.list_issues(
|
||||
overdue_monitoring, overdue_monitoring_due = await _count_and_earliest(
|
||||
db,
|
||||
study_id,
|
||||
overdue=True,
|
||||
limit=2000,
|
||||
))
|
||||
MonitoringVisitIssue,
|
||||
*monitoring_scope,
|
||||
MonitoringVisitIssue.due_at < current_time,
|
||||
)
|
||||
due_soon_monitoring, due_soon_monitoring_due = await _count_and_earliest(
|
||||
db,
|
||||
MonitoringVisitIssue,
|
||||
*monitoring_scope,
|
||||
MonitoringVisitIssue.due_at >= current_time,
|
||||
MonitoringVisitIssue.due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS),
|
||||
)
|
||||
await notification_service.sync_aggregate_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
@@ -67,6 +158,384 @@ async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) ->
|
||||
action_path="/risk-issues/monitoring-visits",
|
||||
source_type="RISK_OVERDUE_MONITORING",
|
||||
source_id=str(study_id),
|
||||
count=overdue_monitoring if can_read_monitoring else 0,
|
||||
count=int(overdue_monitoring) if can_read_monitoring else 0,
|
||||
due_at=_as_utc(overdue_monitoring_due),
|
||||
)
|
||||
await notification_service.sync_aggregate_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category="RISK_MONITORING_DUE_SOON",
|
||||
priority="HIGH",
|
||||
title="监查问题整改时限临近",
|
||||
message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_monitoring} 条监查问题到期",
|
||||
action_path="/risk-issues/monitoring-visits",
|
||||
source_type="RISK_MONITORING_DUE_SOON",
|
||||
source_id=str(study_id),
|
||||
count=int(due_soon_monitoring) if can_read_monitoring else 0,
|
||||
due_at=_as_utc(due_soon_monitoring_due),
|
||||
)
|
||||
|
||||
|
||||
async def _sync_document_distribution_reminders(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
user,
|
||||
role: str,
|
||||
current_time: datetime,
|
||||
) -> None:
|
||||
can_read_documents = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "documents:read"
|
||||
)
|
||||
if not can_read_documents:
|
||||
await notification_service.resolve_missing_recipient_sources(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="DOCUMENT_DISTRIBUTION",
|
||||
active_source_ids=set(),
|
||||
)
|
||||
return
|
||||
site_ids = await site_crud.list_ids_by_contact_user(db, study_id, user.id)
|
||||
target_filters = [
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.USER,
|
||||
Distribution.target_id == str(user.id),
|
||||
),
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.ROLE,
|
||||
Distribution.target_id == role,
|
||||
),
|
||||
]
|
||||
if site_ids:
|
||||
target_filters.append(and_(
|
||||
Distribution.target_type == DistributionTargetType.SITE,
|
||||
Distribution.target_id.in_({str(site_id) for site_id in site_ids}),
|
||||
))
|
||||
rows = (await db.execute(
|
||||
select(Distribution, Document, DocumentVersion)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.outerjoin(
|
||||
Acknowledgement,
|
||||
and_(
|
||||
Acknowledgement.distribution_id == Distribution.id,
|
||||
Acknowledgement.user_id == user.id,
|
||||
Acknowledgement.ack_type == AcknowledgementType.RECEIVED,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Document.trial_id == study_id,
|
||||
Document.status == DocumentStatus.ACTIVE,
|
||||
Distribution.status == DistributionStatus.ACTIVE,
|
||||
Acknowledgement.id.is_(None),
|
||||
or_(*target_filters),
|
||||
)
|
||||
)).all()
|
||||
|
||||
active_source_ids: set[str] = set()
|
||||
for distribution, document, version in rows:
|
||||
source_id = str(distribution.id)
|
||||
active_source_ids.add(source_id)
|
||||
distribution_due_at = _as_utc(distribution.due_at)
|
||||
if distribution_due_at and distribution_due_at < current_time:
|
||||
stage = "OVERDUE"
|
||||
category = "DOCUMENT_ACK_OVERDUE"
|
||||
priority = "URGENT"
|
||||
title = "文件回执已逾期"
|
||||
elif distribution_due_at and distribution_due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS):
|
||||
stage = "DUE_SOON"
|
||||
category = "DOCUMENT_ACK_DUE_SOON"
|
||||
priority = "HIGH"
|
||||
title = "文件回执即将到期"
|
||||
else:
|
||||
stage = "PENDING"
|
||||
category = "DOCUMENT_DISTRIBUTION"
|
||||
priority = "NORMAL"
|
||||
title = "有新的文件版本待接收"
|
||||
due_version = distribution_due_at.isoformat() if distribution_due_at else "none"
|
||||
await notification_service.sync_state_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category=category,
|
||||
priority=priority,
|
||||
title=title,
|
||||
message=f"“{document.title}” {version.version_no} 已分发,请完成接收回执",
|
||||
action_path=f"/documents/{document.id}",
|
||||
source_type="DOCUMENT_DISTRIBUTION",
|
||||
source_id=source_id,
|
||||
source_version=f"{stage}:{due_version}",
|
||||
active=True,
|
||||
due_at=distribution_due_at,
|
||||
)
|
||||
await notification_service.resolve_missing_recipient_sources(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="DOCUMENT_DISTRIBUTION",
|
||||
active_source_ids=active_source_ids,
|
||||
)
|
||||
|
||||
|
||||
async def _sync_milestone_reminders(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
user,
|
||||
role: str,
|
||||
current_time: datetime,
|
||||
) -> None:
|
||||
can_read = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "project_milestones:read"
|
||||
)
|
||||
active_source_ids: set[str] = set()
|
||||
if can_read:
|
||||
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
|
||||
due_soon_date = today + timedelta(days=MILESTONE_DUE_SOON_DAYS)
|
||||
milestones = (await db.scalars(
|
||||
select(Milestone).where(
|
||||
Milestone.study_id == study_id,
|
||||
Milestone.owner_id == user.id,
|
||||
Milestone.status != "DONE",
|
||||
)
|
||||
)).all()
|
||||
for milestone in milestones:
|
||||
due_date = milestone.adjusted_end_date or milestone.planned_date
|
||||
if due_date is None or due_date > due_soon_date:
|
||||
continue
|
||||
source_id = str(milestone.id)
|
||||
active_source_ids.add(source_id)
|
||||
overdue = due_date < today
|
||||
await notification_service.sync_state_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category="MILESTONE_OVERDUE" if overdue else "MILESTONE_DUE_SOON",
|
||||
priority="HIGH" if overdue else "NORMAL",
|
||||
title="项目里程碑已逾期" if overdue else "项目里程碑即将到期",
|
||||
message=f"“{milestone.name}”计划日期为 {due_date.isoformat()}",
|
||||
action_path="/project/milestones",
|
||||
source_type="PROJECT_MILESTONE",
|
||||
source_id=source_id,
|
||||
source_version=f"{'OVERDUE' if overdue else 'DUE_SOON'}:{due_date.isoformat()}:{milestone.status}",
|
||||
active=True,
|
||||
due_at=_date_due_at(due_date),
|
||||
)
|
||||
await notification_service.resolve_missing_recipient_sources(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="PROJECT_MILESTONE",
|
||||
active_source_ids=active_source_ids,
|
||||
)
|
||||
|
||||
|
||||
async def _sync_visit_window_reminders(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
user,
|
||||
role: str,
|
||||
current_time: datetime,
|
||||
) -> None:
|
||||
"""Project active visit windows to the members who can actually maintain them."""
|
||||
can_manage_visits = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "visits:update"
|
||||
)
|
||||
active_source_ids: set[str] = set()
|
||||
if can_manage_visits:
|
||||
cra_scope = await get_cra_site_scope(db, study_id, user)
|
||||
site_ids = cra_scope[0] if cra_scope else None
|
||||
filters = [
|
||||
Visit.study_id == study_id,
|
||||
Visit.actual_date.is_(None),
|
||||
Visit.status.not_in(["DONE", "CANCELLED"]),
|
||||
Subject.status.not_in(["COMPLETED", "DROPPED"]),
|
||||
Site.is_active.is_(True),
|
||||
]
|
||||
if site_ids is not None:
|
||||
filters.append(Subject.site_id.in_(site_ids))
|
||||
rows = (await db.execute(
|
||||
select(Visit, Subject)
|
||||
.join(Subject, Subject.id == Visit.subject_id)
|
||||
.join(Site, Site.id == Subject.site_id)
|
||||
.where(*filters)
|
||||
)).all()
|
||||
|
||||
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
|
||||
due_soon_date = today + timedelta(days=VISIT_WINDOW_DUE_SOON_DAYS)
|
||||
for visit, subject in rows:
|
||||
window_start = visit.window_start or visit.planned_date
|
||||
window_end = visit.window_end or visit.planned_date
|
||||
if window_start is None or window_end is None or window_start > window_end:
|
||||
continue
|
||||
|
||||
if window_end < today:
|
||||
category = "VISIT_WINDOW_MISSED"
|
||||
priority = "HIGH"
|
||||
title = "受试者访视已错过窗口"
|
||||
message = "一个受试者访视已错过计划窗口,请进入详情核对并处理"
|
||||
stage = "MISSED"
|
||||
elif window_start <= due_soon_date:
|
||||
category = "VISIT_WINDOW_DUE_SOON"
|
||||
priority = "NORMAL"
|
||||
title = "受试者访视窗口临近"
|
||||
message = "一个受试者访视已进入近期窗口,请及时核对访视安排"
|
||||
stage = "DUE_SOON"
|
||||
else:
|
||||
continue
|
||||
|
||||
source_id = str(visit.id)
|
||||
active_source_ids.add(source_id)
|
||||
await notification_service.sync_state_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category=category,
|
||||
priority=priority,
|
||||
title=title,
|
||||
message=message,
|
||||
action_path=f"/subjects/{subject.id}",
|
||||
source_type="SUBJECT_VISIT_WINDOW",
|
||||
source_id=source_id,
|
||||
source_version=f"{stage}:{window_start.isoformat()}:{window_end.isoformat()}",
|
||||
active=True,
|
||||
due_at=_date_due_at(window_end),
|
||||
)
|
||||
await notification_service.resolve_missing_recipient_sources(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="SUBJECT_VISIT_WINDOW",
|
||||
active_source_ids=active_source_ids,
|
||||
)
|
||||
|
||||
|
||||
async def _sync_collaboration_edit_request_reminders(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
user,
|
||||
role: str,
|
||||
) -> None:
|
||||
can_read = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "collaboration:read"
|
||||
)
|
||||
if not can_read:
|
||||
await notification_service.resolve_missing_recipient_sources(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="COLLABORATION_EDIT_REQUEST",
|
||||
active_source_ids=set(),
|
||||
)
|
||||
return
|
||||
manages_file = or_(
|
||||
CollaborationFile.owner_id == user.id,
|
||||
select(CollaborationMember.id).where(
|
||||
CollaborationMember.file_id == CollaborationFile.id,
|
||||
CollaborationMember.user_id == user.id,
|
||||
CollaborationMember.role == "MANAGER",
|
||||
).exists(),
|
||||
)
|
||||
rows = (await db.execute(
|
||||
select(CollaborationEditRequest, CollaborationFile, User)
|
||||
.join(CollaborationFile, CollaborationFile.id == CollaborationEditRequest.file_id)
|
||||
.join(User, User.id == CollaborationEditRequest.requester_id)
|
||||
.where(
|
||||
CollaborationFile.study_id == study_id,
|
||||
CollaborationFile.status == "ACTIVE",
|
||||
CollaborationFile.deleted_at.is_(None),
|
||||
CollaborationEditRequest.status == "PENDING",
|
||||
manages_file,
|
||||
)
|
||||
)).all()
|
||||
active_source_ids: set[str] = set()
|
||||
for request, item, requester in rows:
|
||||
source_id = str(request.id)
|
||||
active_source_ids.add(source_id)
|
||||
requester_name = str(requester.full_name or requester.email or "项目成员")
|
||||
await notification_service.sync_state_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category="COLLABORATION_EDIT_REQUEST",
|
||||
priority="NORMAL",
|
||||
title="新的编辑权限申请",
|
||||
message=f"{requester_name} 申请编辑“{item.title}”",
|
||||
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
|
||||
source_type="COLLABORATION_EDIT_REQUEST",
|
||||
source_id=source_id,
|
||||
source_version="PENDING",
|
||||
active=True,
|
||||
dedupe_key=f"collaboration-edit-request:{request.id}",
|
||||
)
|
||||
await notification_service.resolve_missing_recipient_sources(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="COLLABORATION_EDIT_REQUEST",
|
||||
active_source_ids=active_source_ids,
|
||||
)
|
||||
|
||||
|
||||
async def sync_project_reminders(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
user,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
membership = await member_crud.get_member(db, study_id, user.id)
|
||||
if not membership or not membership.is_active:
|
||||
await notification_service.resolve_recipient_study_notifications(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
role = membership.role_in_study
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
if current_time.tzinfo is None:
|
||||
current_time = current_time.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
current_time = current_time.astimezone(timezone.utc)
|
||||
|
||||
await _sync_risk_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role=role,
|
||||
current_time=current_time,
|
||||
)
|
||||
await _sync_document_distribution_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role=role,
|
||||
current_time=current_time,
|
||||
)
|
||||
await _sync_milestone_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role=role,
|
||||
current_time=current_time,
|
||||
)
|
||||
await _sync_visit_window_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role=role,
|
||||
current_time=current_time,
|
||||
)
|
||||
await _sync_collaboration_edit_request_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role=role,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -195,7 +195,7 @@ async def get_login_summaries(
|
||||
"last_login_at": row.login_at,
|
||||
"last_seen_at": row.last_seen_at,
|
||||
"client_type": row.client_type,
|
||||
"active_session_count": 0,
|
||||
"active_sources": set(),
|
||||
},
|
||||
)
|
||||
row_last_seen_at = _as_utc(row.last_seen_at)
|
||||
@@ -204,14 +204,16 @@ async def get_login_summaries(
|
||||
):
|
||||
current["last_seen_at"] = row_last_seen_at
|
||||
if row.ended_at is None and row_last_seen_at >= cutoff:
|
||||
current["active_session_count"] += 1
|
||||
source_key = (row.client_type, row.login_ip) if row.login_ip else ("session", row.id)
|
||||
current["active_sources"].add(source_key)
|
||||
for user_id, item in mutable.items():
|
||||
active_session_count = len(item["active_sources"])
|
||||
summaries[user_id] = UserLoginSummary(
|
||||
status="ONLINE" if item["active_session_count"] else "OFFLINE",
|
||||
status="ONLINE" if active_session_count else "OFFLINE",
|
||||
last_login_at=item["last_login_at"],
|
||||
last_seen_at=item["last_seen_at"],
|
||||
client_type=item["client_type"],
|
||||
active_session_count=item["active_session_count"],
|
||||
active_session_count=active_session_count,
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
@@ -674,24 +674,30 @@ def test_subject_audit_uses_structured_business_snapshot():
|
||||
assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source
|
||||
|
||||
|
||||
def test_faq_audit_uses_business_descriptions():
|
||||
"""医学咨询审计详情应使用业务描述,避免 FAQ 技术文案。"""
|
||||
def test_shared_library_does_not_write_audits():
|
||||
"""共享库各模块及其附件不应写入业务审计。"""
|
||||
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
|
||||
category_source = (api_dir / "faq_categories.py").read_text()
|
||||
item_source = (api_dir / "faqs.py").read_text()
|
||||
services_dir = Path(__file__).resolve().parents[1] / "app" / "services"
|
||||
non_audited_sources = [
|
||||
(api_dir / "faq_categories.py").read_text(),
|
||||
(api_dir / "faqs.py").read_text(),
|
||||
(api_dir / "precautions.py").read_text(),
|
||||
(services_dir / "collaboration_service.py").read_text(),
|
||||
(services_dir / "collaboration_share_service.py").read_text(),
|
||||
(services_dir / "onlyoffice_collaboration_service.py").read_text(),
|
||||
]
|
||||
|
||||
assert 'description": f"创建“{category.name}”分类"' in category_source
|
||||
assert 'description": f"更新“{updated.name}”分类"' in category_source
|
||||
assert 'description": f"删除“{category_name}”分类"' in category_source
|
||||
assert 'f"创建医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"更新医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"回复医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"删除医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"删除医学咨询问题“{question_name}”的回复"' in item_source
|
||||
assert "FAQ 分类 {category.name} 已创建" not in category_source
|
||||
assert 'detail="FAQ 已创建"' not in item_source
|
||||
assert 'detail = "FAQ updated"' not in item_source
|
||||
assert '"description": "创建医学咨询问题"' not in item_source
|
||||
for source in non_audited_sources:
|
||||
assert "audit_crud" not in source
|
||||
assert "._audit(" not in source
|
||||
|
||||
attachment_source = (api_dir / "attachments.py").read_text()
|
||||
assert 'SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}' in attachment_source
|
||||
assert "if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
|
||||
assert "if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
|
||||
|
||||
onlyoffice_source = (api_dir / "onlyoffice.py").read_text()
|
||||
assert "OFFICE_PREVIEW_OPEN" not in onlyoffice_source
|
||||
|
||||
|
||||
def test_domain_audits_use_business_object_names():
|
||||
@@ -704,7 +710,6 @@ def test_domain_audits_use_business_object_names():
|
||||
"material_equipments.py",
|
||||
"visits.py",
|
||||
"aes.py",
|
||||
"precautions.py",
|
||||
"fees_contracts.py",
|
||||
"startup.py",
|
||||
"subject_histories.py",
|
||||
@@ -715,7 +720,6 @@ def test_domain_audits_use_business_object_names():
|
||||
assert "_equipment_audit_detail" in sources["material_equipments.py"]
|
||||
assert "_visit_audit_detail" in sources["visits.py"]
|
||||
assert "_ae_audit_detail" in sources["aes.py"]
|
||||
assert "_precaution_audit_detail" in sources["precautions.py"]
|
||||
assert "_contract_audit_detail" in sources["fees_contracts.py"]
|
||||
assert "_payment_audit_detail" in sources["fees_contracts.py"]
|
||||
assert "_feasibility_audit_detail" in sources["startup.py"]
|
||||
|
||||
@@ -140,7 +140,6 @@ async def test_copy_revision_saves_to_the_selected_workspace_folder(monkeypatch,
|
||||
content=content,
|
||||
source="COPY",
|
||||
user=user,
|
||||
source_file_id=item.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -159,22 +158,13 @@ async def test_delete_revision_soft_deletes_a_non_current_version(monkeypatch):
|
||||
)
|
||||
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
|
||||
require_manager = AsyncMock()
|
||||
audit = AsyncMock()
|
||||
monkeypatch.setattr(collaboration_service, "require_file_manager", require_manager)
|
||||
monkeypatch.setattr(collaboration_service, "_audit", audit)
|
||||
|
||||
await collaboration_service.delete_revision(db, item, revision_id, user)
|
||||
|
||||
require_manager.assert_awaited_once_with(db, item, user)
|
||||
assert revision.deleted_at is not None
|
||||
assert revision.deleted_by == user.id
|
||||
audit.assert_awaited_once_with(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_REVISION_DELETED",
|
||||
user,
|
||||
{"revision_id": str(revision_id), "revision_no": 2},
|
||||
)
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -260,7 +250,6 @@ async def test_sheet_permission_change_updates_current_file_without_creating_rev
|
||||
user = SimpleNamespace(id=uuid.uuid4())
|
||||
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock(), refresh=AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
|
||||
persist_revision = AsyncMock()
|
||||
monkeypatch.setattr(collaboration_service, "_persist_revision_bytes", persist_revision)
|
||||
|
||||
@@ -540,7 +529,6 @@ async def test_share_link_url_stays_immutable_when_disabled_and_reenabled(monkey
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
|
||||
original_token = collaboration_share_service.share_token(link)
|
||||
|
||||
await collaboration_share_service.update_share_link(
|
||||
@@ -803,12 +791,11 @@ async def test_copy_creates_an_independent_r1_from_the_current_revision(monkeypa
|
||||
content=content,
|
||||
source="COPY",
|
||||
user=user,
|
||||
source_file_id=item.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_returns_current_revision_and_records_audit(monkeypatch, tmp_path):
|
||||
async def test_download_returns_current_revision_without_writing_audit(monkeypatch, tmp_path):
|
||||
source = tmp_path / "source.docx"
|
||||
source.write_bytes(collaboration_service.blank_file_bytes("word"))
|
||||
revision = SimpleNamespace(
|
||||
@@ -821,22 +808,33 @@ async def test_download_returns_current_revision_and_records_audit(monkeypatch,
|
||||
user = SimpleNamespace(id=uuid.uuid4())
|
||||
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
|
||||
require_exporter = AsyncMock()
|
||||
audit = AsyncMock()
|
||||
monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter)
|
||||
monkeypatch.setattr(collaboration_service, "_audit", audit)
|
||||
|
||||
result = await collaboration_service.prepare_download(db, item, user)
|
||||
|
||||
assert result is revision
|
||||
require_exporter.assert_awaited_once_with(db, item, user)
|
||||
audit.assert_awaited_once_with(
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revision_preview_does_not_write_passive_audit(monkeypatch, tmp_path):
|
||||
revision_path = tmp_path / "revision.docx"
|
||||
revision_path.write_bytes(b"office")
|
||||
revision = SimpleNamespace(id=uuid.uuid4(), file_uri=str(revision_path), revision_no=2)
|
||||
item = SimpleNamespace(id=uuid.uuid4())
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service, "get_revision_or_404", AsyncMock(return_value=revision))
|
||||
|
||||
result = await collaboration_service.prepare_revision_preview(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_FILE_DOWNLOADED",
|
||||
user,
|
||||
{"revision_id": str(revision.id), "revision_no": 3},
|
||||
revision.id,
|
||||
SimpleNamespace(id=uuid.uuid4()),
|
||||
)
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
assert result is revision
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1041,12 +1039,6 @@ async def test_force_save_updates_session_recovery_revision(monkeypatch):
|
||||
"append_revision",
|
||||
AsyncMock(return_value=(revision, True)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
onlyoffice_collaboration_service.collaboration_service,
|
||||
"_audit",
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
result = await onlyoffice_collaboration_service.process_callback(
|
||||
db,
|
||||
session.id,
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import visits as visits_api
|
||||
from app.models.notification import Notification
|
||||
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||
from app.models.collaboration import CollaborationEditRequest
|
||||
from app.services import collaboration_service, notification_service, project_reminder_service
|
||||
from app.services import collaboration_service, desktop_notification_service, notification_service, project_reminder_service
|
||||
|
||||
|
||||
def test_generic_notification_table_has_recipient_dedupe_and_state_indexes():
|
||||
table = Notification.__table__
|
||||
assert table.name == "notifications"
|
||||
assert {"study_id", "recipient_id", "category", "action_path", "source_type", "source_id", "read_at", "resolved_at"} <= set(table.columns.keys())
|
||||
assert {
|
||||
"study_id", "recipient_id", "category", "action_path", "source_type", "source_id",
|
||||
"requires_action", "due_at", "read_at", "resolved_at",
|
||||
} <= set(table.columns.keys())
|
||||
assert any(constraint.name == "uq_notifications_recipient_dedupe" for constraint in table.constraints)
|
||||
assert {index.name for index in table.indexes} >= {
|
||||
"ix_notifications_recipient_study_state",
|
||||
@@ -21,6 +26,23 @@ def test_generic_notification_table_has_recipient_dedupe_and_state_indexes():
|
||||
}
|
||||
|
||||
|
||||
def test_desktop_delivery_targets_the_generic_notification_feed():
|
||||
table = DesktopNotificationDelivery.__table__
|
||||
assert table.columns.notification_id.nullable is True
|
||||
assert any(
|
||||
constraint.name == "uq_desktop_notification_user_notification"
|
||||
for constraint in table.constraints
|
||||
)
|
||||
statement = str(desktop_notification_service._eligible_query(
|
||||
uuid.uuid4(),
|
||||
datetime.now(timezone.utc),
|
||||
datetime.now(timezone.utc),
|
||||
))
|
||||
assert "notifications" in statement
|
||||
assert "notification_id" in statement
|
||||
assert "resolved_at" in statement
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recipient_notification_creation_is_deduplicated_per_recipient():
|
||||
existing_id = uuid.uuid4()
|
||||
@@ -64,24 +86,237 @@ async def test_resolving_a_source_closes_every_recipient_notification():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_risks_are_materialized_into_the_generic_notification_table(monkeypatch):
|
||||
async def test_project_reminder_sync_runs_every_supported_rule_group(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
|
||||
membership = SimpleNamespace(is_active=True, role_in_study="PM")
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
sync = AsyncMock()
|
||||
risk_sync = AsyncMock()
|
||||
distribution_sync = AsyncMock()
|
||||
milestone_sync = AsyncMock()
|
||||
visit_sync = AsyncMock()
|
||||
collaboration_sync = AsyncMock()
|
||||
monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=membership))
|
||||
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(project_reminder_service.ae_crud, "list_ae", AsyncMock(return_value=[object(), object()]))
|
||||
monkeypatch.setattr(project_reminder_service.monitoring_issue_crud, "list_issues", AsyncMock(return_value=[object()]))
|
||||
monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync)
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_risk_reminders", risk_sync)
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_document_distribution_reminders", distribution_sync)
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_milestone_reminders", milestone_sync)
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_visit_window_reminders", visit_sync)
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_collaboration_edit_request_reminders", collaboration_sync)
|
||||
|
||||
await project_reminder_service.sync_project_reminders(db, study_id, user)
|
||||
|
||||
assert sync.await_count == 2
|
||||
assert sync.await_args_list[0].kwargs["count"] == 2
|
||||
assert sync.await_args_list[1].kwargs["count"] == 1
|
||||
risk_sync.assert_awaited_once()
|
||||
distribution_sync.assert_awaited_once()
|
||||
milestone_sync.assert_awaited_once()
|
||||
visit_sync.assert_awaited_once()
|
||||
collaboration_sync.assert_awaited_once()
|
||||
assert collaboration_sync.await_args.kwargs["role"] == "PM"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inactive_membership_resolves_stale_project_notifications(monkeypatch):
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
resolve = AsyncMock()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=True)
|
||||
study_id = uuid.uuid4()
|
||||
monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service.notification_service,
|
||||
"resolve_recipient_study_notifications",
|
||||
resolve,
|
||||
)
|
||||
|
||||
await project_reminder_service.sync_project_reminders(db, study_id, user)
|
||||
|
||||
resolve.assert_awaited_once_with(db, study_id=study_id, recipient_id=user.id)
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_risks_include_due_soon_and_overdue_aggregates(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
|
||||
now = datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc)
|
||||
db = SimpleNamespace()
|
||||
sync = AsyncMock()
|
||||
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service,
|
||||
"_count_and_earliest",
|
||||
AsyncMock(side_effect=[
|
||||
(2, date(2026, 7, 15)),
|
||||
(1, date(2026, 7, 18)),
|
||||
(3, now - timedelta(hours=2)),
|
||||
(4, now + timedelta(days=1)),
|
||||
]),
|
||||
)
|
||||
monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync)
|
||||
|
||||
await project_reminder_service._sync_risk_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role="PM",
|
||||
current_time=now,
|
||||
)
|
||||
|
||||
assert [call.kwargs["category"] for call in sync.await_args_list] == [
|
||||
"RISK_OVERDUE_AE",
|
||||
"RISK_AE_DUE_SOON",
|
||||
"RISK_OVERDUE_MONITORING",
|
||||
"RISK_MONITORING_DUE_SOON",
|
||||
]
|
||||
assert [call.kwargs["count"] for call in sync.await_args_list] == [2, 1, 3, 4]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visit_window_reminders_are_actionable_scoped_and_privacy_safe(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
|
||||
site_id = uuid.uuid4()
|
||||
due_visit_id = uuid.uuid4()
|
||||
missed_visit_id = uuid.uuid4()
|
||||
due_subject_id = uuid.uuid4()
|
||||
missed_subject_id = uuid.uuid4()
|
||||
due_visit = SimpleNamespace(
|
||||
id=due_visit_id,
|
||||
planned_date=date(2026, 7, 19),
|
||||
window_start=date(2026, 7, 18),
|
||||
window_end=date(2026, 7, 20),
|
||||
)
|
||||
missed_visit = SimpleNamespace(
|
||||
id=missed_visit_id,
|
||||
planned_date=date(2026, 7, 14),
|
||||
window_start=date(2026, 7, 13),
|
||||
window_end=date(2026, 7, 15),
|
||||
)
|
||||
due_subject = SimpleNamespace(id=due_subject_id, subject_no="S-PRIVACY-001")
|
||||
missed_subject = SimpleNamespace(id=missed_subject_id, subject_no="S-PRIVACY-002")
|
||||
rows = SimpleNamespace(all=lambda: [
|
||||
(due_visit, due_subject),
|
||||
(missed_visit, missed_subject),
|
||||
])
|
||||
db = SimpleNamespace(execute=AsyncMock(return_value=rows))
|
||||
sync = AsyncMock()
|
||||
resolve = AsyncMock()
|
||||
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service,
|
||||
"get_cra_site_scope",
|
||||
AsyncMock(return_value=({site_id}, {"研究中心"})),
|
||||
)
|
||||
monkeypatch.setattr(project_reminder_service.notification_service, "sync_state_notification", sync)
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service.notification_service,
|
||||
"resolve_missing_recipient_sources",
|
||||
resolve,
|
||||
)
|
||||
|
||||
await project_reminder_service._sync_visit_window_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role="CRA",
|
||||
current_time=datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert [call.kwargs["category"] for call in sync.await_args_list] == [
|
||||
"VISIT_WINDOW_DUE_SOON",
|
||||
"VISIT_WINDOW_MISSED",
|
||||
]
|
||||
assert [call.kwargs["priority"] for call in sync.await_args_list] == ["NORMAL", "HIGH"]
|
||||
assert sync.await_args_list[0].kwargs["action_path"] == f"/subjects/{due_subject_id}"
|
||||
assert sync.await_args_list[1].kwargs["action_path"] == f"/subjects/{missed_subject_id}"
|
||||
messages = " ".join(call.kwargs["message"] for call in sync.await_args_list)
|
||||
assert "S-PRIVACY-001" not in messages
|
||||
assert "S-PRIVACY-002" not in messages
|
||||
resolve.assert_awaited_once_with(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="SUBJECT_VISIT_WINDOW",
|
||||
active_source_ids={str(due_visit_id), str(missed_visit_id)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visit_window_reminders_resolve_when_recipient_cannot_manage_visits(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
|
||||
db = SimpleNamespace(execute=AsyncMock())
|
||||
resolve = AsyncMock()
|
||||
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service.notification_service,
|
||||
"resolve_missing_recipient_sources",
|
||||
resolve,
|
||||
)
|
||||
|
||||
await project_reminder_service._sync_visit_window_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role="QA",
|
||||
current_time=datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
db.execute.assert_not_awaited()
|
||||
resolve.assert_awaited_once_with(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="SUBJECT_VISIT_WINDOW",
|
||||
active_source_ids=set(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_visit_closes_every_recipient_reminder_immediately(monkeypatch):
|
||||
first_id = uuid.uuid4()
|
||||
second_id = uuid.uuid4()
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
resolve = AsyncMock()
|
||||
monkeypatch.setattr(visits_api.notification_service, "resolve_source_notifications", resolve)
|
||||
|
||||
await visits_api._resolve_visit_reminders(db, [first_id, second_id, first_id])
|
||||
|
||||
assert resolve.await_count == 2
|
||||
assert {call.kwargs["source_id"] for call in resolve.await_args_list} == {
|
||||
str(first_id),
|
||||
str(second_id),
|
||||
}
|
||||
assert all(
|
||||
call.kwargs["source_type"] == "SUBJECT_VISIT_WINDOW"
|
||||
for call in resolve.await_args_list
|
||||
)
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reading_an_informational_notification_archives_it():
|
||||
item = SimpleNamespace(
|
||||
id=uuid.uuid4(),
|
||||
read_at=None,
|
||||
resolved_at=None,
|
||||
requires_action=False,
|
||||
)
|
||||
db = SimpleNamespace(
|
||||
scalar=AsyncMock(return_value=item),
|
||||
commit=AsyncMock(),
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
|
||||
result = await notification_service.mark_read(
|
||||
db,
|
||||
study_id=uuid.uuid4(),
|
||||
recipient_id=uuid.uuid4(),
|
||||
notification_id=item.id,
|
||||
)
|
||||
|
||||
assert result.read_at is not None
|
||||
assert result.resolved_at == result.read_at
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -128,7 +363,6 @@ async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypa
|
||||
"get_member",
|
||||
AsyncMock(return_value=SimpleNamespace(is_active=True)),
|
||||
)
|
||||
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify)
|
||||
|
||||
result = await collaboration_service.create_edit_request(db, item, user)
|
||||
|
||||
@@ -39,12 +39,10 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
permission = AsyncMock()
|
||||
audit = AsyncMock()
|
||||
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission)
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", audit)
|
||||
response = Response()
|
||||
db = object()
|
||||
|
||||
@@ -54,7 +52,6 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
|
||||
assert result.file_name == "研究方案最终版.DOCX"
|
||||
assert result.config["document"]["title"] == "研究方案最终版.DOCX"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
audit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -96,7 +93,6 @@ async def test_version_config_reuses_document_access_and_version_hash(monkeypatc
|
||||
monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document))
|
||||
monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access)
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", AsyncMock())
|
||||
|
||||
result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user)
|
||||
|
||||
|
||||
@@ -134,6 +134,55 @@ async def test_login_summary_marks_recent_unended_sessions_online(db_session, mo
|
||||
assert summaries[user.id].client_type == "desktop"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_summary_counts_same_client_and_ip_as_one_active_source(db_session, monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="deduplicated-login-source@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Deduplicated Login Source",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(user)
|
||||
db_session.add_all(
|
||||
[
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
client_type="web",
|
||||
login_ip="192.168.97.1",
|
||||
login_at=now - timedelta(minutes=2),
|
||||
last_seen_at=now - timedelta(seconds=20),
|
||||
),
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
client_type="web",
|
||||
login_ip="192.168.97.1",
|
||||
login_at=now - timedelta(minutes=1),
|
||||
last_seen_at=now - timedelta(seconds=10),
|
||||
),
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
client_type="desktop",
|
||||
login_ip="192.168.97.1",
|
||||
login_at=now - timedelta(minutes=1),
|
||||
last_seen_at=now - timedelta(seconds=5),
|
||||
),
|
||||
]
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
summaries = await get_login_summaries(db_session, [user.id])
|
||||
|
||||
assert summaries[user.id].status == "ONLINE"
|
||||
assert summaries[user.id].active_session_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_list_filters_online_and_offline_accounts(db_session, monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
+5
-5
@@ -38,6 +38,7 @@ services:
|
||||
MONITORING_RETENTION_INTERVAL_SECONDS: ${MONITORING_RETENTION_INTERVAL_SECONDS:-86400}
|
||||
USER_LOGIN_ACTIVITY_RETENTION_DAYS: ${USER_LOGIN_ACTIVITY_RETENTION_DAYS:-180}
|
||||
USER_SESSION_ONLINE_SECONDS: ${USER_SESSION_ONLINE_SECONDS:-300}
|
||||
NOTIFICATION_SYNC_INTERVAL_SECONDS: ${NOTIFICATION_SYNC_INTERVAL_SECONDS:-300}
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32,::1/128,172.16.0.0/12,192.168.0.0/16}
|
||||
MONITORING_SERVER_PUBLIC_IP: ${MONITORING_SERVER_PUBLIC_IP:-}
|
||||
MONITORING_PUBLIC_IP_DISCOVERY_URLS: ${MONITORING_PUBLIC_IP_DISCOVERY_URLS:-https://api64.ipify.org,https://icanhazip.com}
|
||||
@@ -46,11 +47,11 @@ services:
|
||||
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: ${MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS:-2.5}
|
||||
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: ${MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS:-604800}
|
||||
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: ${MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS:-10}
|
||||
ONLYOFFICE_ENABLED: ${ONLYOFFICE_ENABLED:-false}
|
||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-}
|
||||
ONLYOFFICE_ENABLED: ${ONLYOFFICE_ENABLED:-true}
|
||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?请先运行安装脚本生成 ONLYOFFICE_JWT_SECRET}
|
||||
ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice}
|
||||
ONLYOFFICE_STORAGE_BASE_URL: ${ONLYOFFICE_STORAGE_BASE_URL:-http://backend:8000}
|
||||
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:-}
|
||||
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:?请先运行安装脚本生成 ONLYOFFICE_INSTANCE_ID}
|
||||
ONLYOFFICE_CONFIG_TTL_SECONDS: ${ONLYOFFICE_CONFIG_TTL_SECONDS:-300}
|
||||
COLLABORATION_MAX_FILE_BYTES: ${COLLABORATION_MAX_FILE_BYTES:-52428800}
|
||||
depends_on:
|
||||
@@ -81,10 +82,9 @@ services:
|
||||
image: ctms-onlyoffice:9.4.0.1
|
||||
container_name: ctms_onlyoffice
|
||||
restart: always
|
||||
profiles: ["office"]
|
||||
environment:
|
||||
JWT_ENABLED: "true"
|
||||
JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-}
|
||||
JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?请先运行安装脚本生成 ONLYOFFICE_JWT_SECRET}
|
||||
JWT_HEADER: AuthorizationJwt
|
||||
JWT_IN_BODY: "false"
|
||||
EXAMPLE_ENABLED: "false"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# CTMS Desktop Release Stabilization Checklist
|
||||
|
||||
状态: `active`
|
||||
适用范围: Web 与 macOS Desktop 统一客户端发布
|
||||
最后更新: `2026-07-14`
|
||||
适用范围: Web、macOS Desktop 与 Windows x64 Desktop 统一客户端发布
|
||||
最后更新: `2026-07-17`
|
||||
|
||||
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。当前允许按 `docs/desktop-local-cache-plan.md` 引入在线辅助本地缓存,但不引入离线登录、离线写入、本地业务权威数据、内嵌后端服务或离线同步。
|
||||
|
||||
@@ -28,12 +28,14 @@ npm run desktop:build:app
|
||||
|
||||
- [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。
|
||||
- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。
|
||||
- [ ] 在正式 release tag 和签名环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址齐备。
|
||||
- [ ] macOS app 已签名和公证。
|
||||
- [ ] 在正式 release tag 环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、当前精确版本的平台签名策略、updater 私钥和生产 artifact HTTPS 基址齐备。
|
||||
- [ ] macOS app 已完成 Apple 签名/公证;若当前精确版本获批平台签名例外,则已验证 `Signature=adhoc`、确认未公证并附带 Gatekeeper 风险说明。
|
||||
- [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 返回 `Valid`;若当前精确版本获批例外,则应用和安装器必须返回 `NotSigned` 并附带 SmartScreen 风险说明。
|
||||
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。
|
||||
- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build:macos-release -- --ci`。
|
||||
- [ ] 正式 updater feed 先执行 `npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>` 生成 `latest.json` 与 `SHA256SUMS.txt`。
|
||||
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir>`,并确认 checksum manifest、updater artifact、`.sig` 和 `latest.json` 均通过校验。
|
||||
- [ ] 始终设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 `REQUIRE_UPDATER_SIGNING=true`;默认 macOS 路径再设置 Apple 变量和 `REQUIRE_DESKTOP_SIGNING=true`,例外路径则设置 `DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception`、`ALLOW_UNSIGNED_PLATFORM_RELEASE=true` 并执行 `npm run desktop:build:macos-unsigned-release -- --ci`。
|
||||
- [ ] 默认 Windows 路径设置 PFX、密码、`WINDOWS_TIMESTAMP_URL` 和 `REQUIRE_WINDOWS_SIGNING=true`;例外路径不得伪装签名状态,构建后必须对应用和安装器验证 `NotSigned`。
|
||||
- [ ] 正式 updater feed 使用 `--artifact <CTMS.app.tar.gz>` 和 `--platform-artifact windows-x86_64=<CTMS.nsis.zip>` 汇总生成同一个 `latest.json` 与 `SHA256SUMS.txt`。
|
||||
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`,并确认两个平台的 updater artifact、`.sig`、安装包、provenance、checksum manifest 和 `latest.json` 均通过校验。
|
||||
- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。
|
||||
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
|
||||
|
||||
@@ -68,7 +70,7 @@ npm run desktop:build:app
|
||||
- [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。
|
||||
- [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。
|
||||
- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。
|
||||
- [ ] signed macOS release candidate workflow 只允许从 `vX.Y.Z` tag 运行,并包含签名环境检查、Universal macOS 构建、update feed 生成、checksum 校验和 verified release directory 上传。
|
||||
- [ ] Desktop release candidate workflow 只允许从 `vX.Y.Z` tag 运行;先按精确版本解析平台签名策略,macOS 和 Windows 原生 job 分别验证实际签名状态,再由聚合 job 生成包含 `darwin-aarch64`、`darwin-x86_64` 和 `windows-x86_64` 的 feed、provenance、checksum 清单和 verified release directory。
|
||||
|
||||
人工复审还必须确认:
|
||||
|
||||
@@ -86,7 +88,8 @@ npm run desktop:build:app
|
||||
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
|
||||
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
|
||||
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
|
||||
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
||||
| 构建默认服务器地址已注入 | 不适用 | 必测 | 首次启动使用 `VITE_DESKTOP_SERVER_URL`,不要求重复输入;运行时代码不含生产域名 |
|
||||
| 构建默认地址和本地配置均缺失 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
||||
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
|
||||
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
|
||||
| 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 |
|
||||
@@ -261,3 +264,35 @@ npm run desktop:build:app
|
||||
真实 `.app` 仍需人工验证:红色按钮真正关闭主窗口后 Dock 重建、菜单顺序、个人中心自身关闭按钮、编辑菜单文本输入行为、自定义快捷键即时同步,以及系统明暗模式切换时的标题栏一致性。
|
||||
|
||||
本轮未引入以下条件性 Swift 能力:Quick Look 需先确认附件审阅频率和 Windows 降级语义;Touch ID 会改变现有 30 天会话恢复体验,需先确定凭据访问控制策略;通知点击回跳需先定义固定、无敏感信息的动作和目标页面。三项均不是当前发布前置条件。
|
||||
|
||||
## 12. 2026-07-17 Windows 正式发布链路记录
|
||||
|
||||
经发布负责人明确批准,Windows x64 NSIS 从内部兼容性验证目标提升为正式桌面发布目标。本轮完成:
|
||||
|
||||
- 正式 Desktop candidate workflow 拆分为 macOS 签名/公证、Windows 代码签名和跨平台聚合三个 job;两端都只接受与客户端版本一致的 `vX.Y.Z` tag。
|
||||
- Windows job 从组织 secret 导入 Base64 PFX,动态配置证书指纹、SHA-256、RFC 3161 时间戳,构建 NSIS installer/updater,并校验应用与安装器的 Authenticode signer 和 timestamp certificate。
|
||||
- 聚合 job 只在两个原生 job 均通过后生成统一 `latest.json`,同时包含 Universal macOS 的两个平台键和 `windows-x86_64`,并校验所有制品、`.sig` 与 checksum。
|
||||
- 无签名 `.github/workflows/desktop-windows-internal.yml` 继续保持 branch-only 内部验证语义,不生成正式 updater feed。
|
||||
- npm 依赖锁文件已升级至当前安全修复版本,生产和完整依赖审计均为 0 个已知漏洞;Axios 新 header 类型通过受控字符串归一化适配并新增单元测试。
|
||||
|
||||
创建正式 tag 前仍必须由发布负责人确认:
|
||||
|
||||
- GitHub Actions 已配置 updater secrets 和 versioned artifact base URL;默认平台签名路径还必须配置 Apple 与 Windows secrets 和 Windows timestamp URL。
|
||||
- 若使用默认 Windows 签名路径,组织证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。
|
||||
- macOS/Windows 原生 candidate job、联合 feed 校验、真实安装/升级和生产下载源上传全部通过。
|
||||
|
||||
## 13. 2026-07-17 v0.1.0 平台签名例外记录
|
||||
|
||||
经发布负责人明确批准,v0.1.0 可在无法提供 Apple Developer 和 Windows 组织代码签名凭据时受控分发。该批准仅覆盖精确版本 `0.1.0`:
|
||||
|
||||
- `frontend/desktop-release-policy.json` 默认仍为 `signed`,仅将 v0.1.0 列入 `unsignedPlatformExceptions`;后续版本不会继承本例外。
|
||||
- macOS 使用 Tauri `signingIdentity: "-"` 生成 ad-hoc 签名 Universal app/DMG,必须通过 `codesign --verify` 并确认 `Signature=adhoc`,不执行或声称 Apple 公证。
|
||||
- Windows 应用和 NSIS 安装器不做 Authenticode 签名,必须通过 `Get-AuthenticodeSignature` 确认 `NotSigned`,不执行或声称 RFC 3161 时间戳。
|
||||
- 两端仍必须从同一 `v0.1.0` tag 和 SHA 构建,使用同一 updater 私钥生成 `.sig`,并通过联合 feed、checksum 和 provenance 校验。
|
||||
- GitHub Actions 额度不可用期间,发布负责人额外批准从最终 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS 制品;Windows 必须在恢复后从同一 tag/SHA 后补。
|
||||
- macOS 先行的用户可见 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 上验证。
|
||||
|
||||
@@ -305,9 +305,33 @@ 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)
|
||||
|
||||
@@ -77,12 +77,12 @@ API:
|
||||
- `POST /api/v1/desktop-notifications/ack`
|
||||
- `POST /api/v1/desktop-notifications/{distribution_id}/read`
|
||||
|
||||
claim 跨有效项目查询匹配当前用户或角色的活动文件分发,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。
|
||||
claim 跨有效项目查询当前用户未读且未解决的通用业务提醒,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。系统通知只是通用提醒 Feed 的可选投递通道,不单独维护文件分发提醒来源。
|
||||
|
||||
系统通知正文只显示通用内容:
|
||||
|
||||
- 标题:`CTMS 文件更新`
|
||||
- 正文:`有新的文件版本待查看`
|
||||
- 标题:`CTMS 待办提醒`
|
||||
- 正文:`有新的业务提醒待查看`
|
||||
|
||||
项目、文件、版本等详细信息仅在应用内列表显示,避免锁屏泄露。
|
||||
|
||||
|
||||
@@ -147,12 +147,12 @@ bash scripts/install.sh release --base-url https://ctms.example.com
|
||||
|
||||
```text
|
||||
1. 检查 docker、docker compose、openssl、curl。
|
||||
2. 检查 .env;已存在则保留不改,不存在则新建。
|
||||
3. 新建 main/release 的 .env 时自动生成 JWT 和 RSA 私钥。
|
||||
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建。
|
||||
3. 所有环境自动生成独立的 ONLYOFFICE JWT 与稳定实例标识;新建 main/release 的 .env 时同时生成登录 JWT 和 RSA 私钥。
|
||||
4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。
|
||||
5. 启动容器;默认执行 docker compose up -d --build,--skip-build 时执行 docker compose up -d。
|
||||
5. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
|
||||
6. 执行 docker compose run --rm backend python -m alembic upgrade head。
|
||||
7. 检查容器状态、后端环境、/health、/api/v1/auth/login-key。
|
||||
7. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
|
||||
```
|
||||
|
||||
更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。
|
||||
@@ -195,6 +195,12 @@ ENV=development
|
||||
JWT_SECRET_KEY=dev-secret
|
||||
LOGIN_RSA_KEY_ID=default
|
||||
LOGIN_RSA_PRIVATE_KEY=
|
||||
ONLYOFFICE_ENABLED=true
|
||||
ONLYOFFICE_JWT_SECRET=<替换为独立的随机密钥,至少 32 个字符>
|
||||
ONLYOFFICE_INTERNAL_URL=http://onlyoffice
|
||||
ONLYOFFICE_STORAGE_BASE_URL=http://backend:8000
|
||||
ONLYOFFICE_INSTANCE_ID=ctms-dev-<替换为随机实例标识>
|
||||
ONLYOFFICE_CONFIG_TTL_SECONDS=300
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -316,6 +322,12 @@ ENV=production
|
||||
JWT_SECRET_KEY=<替换为 JWT 密钥>
|
||||
LOGIN_RSA_KEY_ID=main-YYYYMMDD
|
||||
LOGIN_RSA_PRIVATE_KEY=<替换为 RSA 私钥单行文本>
|
||||
ONLYOFFICE_ENABLED=true
|
||||
ONLYOFFICE_JWT_SECRET=<替换为不同于 JWT_SECRET_KEY 的独立随机密钥>
|
||||
ONLYOFFICE_INTERNAL_URL=http://onlyoffice
|
||||
ONLYOFFICE_STORAGE_BASE_URL=http://backend:8000
|
||||
ONLYOFFICE_INSTANCE_ID=ctms-main-<替换为随机实例标识>
|
||||
ONLYOFFICE_CONFIG_TTL_SECONDS=300
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -495,6 +507,12 @@ ENV=production
|
||||
JWT_SECRET_KEY=<替换为生产 JWT 密钥>
|
||||
LOGIN_RSA_KEY_ID=release-YYYYMMDD
|
||||
LOGIN_RSA_PRIVATE_KEY=<替换为生产 RSA 私钥单行文本>
|
||||
ONLYOFFICE_ENABLED=true
|
||||
ONLYOFFICE_JWT_SECRET=<替换为不同于 JWT_SECRET_KEY 的独立生产随机密钥>
|
||||
ONLYOFFICE_INTERNAL_URL=http://onlyoffice
|
||||
ONLYOFFICE_STORAGE_BASE_URL=http://backend:8000
|
||||
ONLYOFFICE_INSTANCE_ID=ctms-release-<替换为随机实例标识>
|
||||
ONLYOFFICE_CONFIG_TTL_SECONDS=300
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
@@ -106,8 +106,9 @@ npm run desktop:build:app
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
正式桌面发布构建仍必须设置 updater 签名私钥和 Apple 签名/公证变量后,先执行
|
||||
`npm run desktop:release-readiness:check`,再执行 `npm run desktop:build:macos-release -- --ci`。
|
||||
正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥,updater 签名不可关闭。平台签名默认路径仍要求 macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行,Windows 设置 PFX、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行。只有 `frontend/desktop-release-policy.json` 对当前精确版本存在已批准例外时,workflow 才可改为 macOS ad-hoc、Windows Authenticode 未签名路径;例外制品必须标注 `UNSIGNED-PLATFORM`、生成 provenance 和风险说明。两个平台与联合 feed 校验通过后才能汇总正式 updater feed。
|
||||
|
||||
v0.1.0 额外批准 GitHub Actions 额度不可用时的分阶段应急路径:先在受控 macOS 主机从最终、不可移动的 `v0.1.0` tag/SHA 本地构建 macOS ad-hoc 制品,面向安装用户的 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` 实物验证完成。
|
||||
|
||||
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
|
||||
|
||||
@@ -207,6 +208,7 @@ dev -> main
|
||||
- 前后端测试通过
|
||||
- 网页端构建通过
|
||||
- macOS 桌面端构建通过
|
||||
- Windows x64 NSIS 兼容性构建通过;正式发布时平台签名和时间戳验证通过,或当前精确版本的已批准平台签名例外验证通过
|
||||
- 数据库迁移经过验证
|
||||
- 已知风险和回滚方式已记录
|
||||
|
||||
@@ -281,7 +283,7 @@ main -> release
|
||||
- 回归测试通过
|
||||
- 数据库迁移和回滚方案确认
|
||||
- 网页端生产构建通过
|
||||
- 桌面端生产构建通过
|
||||
- macOS 与 Windows 桌面端生产构建均从候选 tag 通过
|
||||
- 发布说明完成
|
||||
- 生产配置和密钥不在仓库中
|
||||
- 正式版本号已经统一
|
||||
@@ -295,7 +297,9 @@ git tag -a v1.2.0 -m "CTMS v1.2.0"
|
||||
git push origin v1.2.0
|
||||
```
|
||||
|
||||
网页端和桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。
|
||||
网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。两个平台的 updater 签名制品必须始终验证通过;macOS 签名/公证和 Windows 代码签名/RFC 3161 时间戳必须验证通过,除非 `frontend/desktop-release-policy.json` 对该精确版本记录了已批准例外。采用例外时必须验证 macOS 确为 ad-hoc、Windows 确为 `NotSigned`,并在 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。
|
||||
|
||||
发布记录至少包含:
|
||||
|
||||
@@ -450,6 +454,7 @@ git log --oneline --decorate --graph --all -30
|
||||
- [ ] `npm run build` 通过
|
||||
- [ ] `npm run desktop:build:app` 通过
|
||||
- [ ] 正式桌面发布构建已使用 updater 签名私钥执行
|
||||
- [ ] 平台签名已验证,或当前精确版本已在 `frontend/desktop-release-policy.json` 获批例外且 Release Notes、私有 `UNSIGNED-PLATFORM` 证据、provenance 和完整 updater checksum 齐备;若 GitHub Release 采用 installer-only profile,公开 checksum 只覆盖公开安装包
|
||||
- [ ] 数据库迁移与回滚方案确认
|
||||
- [ ] 发布说明完成
|
||||
- [ ] `main -> release` 合并完成
|
||||
|
||||
+129
-28
@@ -66,8 +66,8 @@ Manual edits that leave these files inconsistent fail CI.
|
||||
## Stabilization Gates
|
||||
|
||||
Client builds require Node.js 22.13.0 or newer. The development frontend container,
|
||||
Web CI, macOS release candidates, Windows internal validation, and the production
|
||||
Web image use the same Node 22.13 baseline.
|
||||
Web CI, macOS and Windows release candidates, Windows internal validation, and the
|
||||
production Web image use the same Node 22.13 baseline.
|
||||
|
||||
Client release candidates must pass the shared Web checks and the Desktop
|
||||
release/security gate before promotion:
|
||||
@@ -85,8 +85,13 @@ npm run build
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
`release:env:check` verifies build channel and commit metadata, and can be
|
||||
made strict for signed Desktop builds with `REQUIRE_DESKTOP_SIGNING=true`.
|
||||
`release:env:check` verifies build channel and commit metadata, and becomes
|
||||
platform-strict with `REQUIRE_DESKTOP_SIGNING=true` on macOS or
|
||||
`REQUIRE_WINDOWS_SIGNING=true` on Windows. Formal native jobs also set
|
||||
`REQUIRE_UPDATER_SIGNING=true`, `DESKTOP_RELEASE_PLATFORM`, and the
|
||||
version-derived `DESKTOP_PLATFORM_SIGNING_MODE`. The exact-version policy in
|
||||
`frontend/desktop-release-policy.json` is checked with
|
||||
`npm run desktop:release-policy:check`.
|
||||
`desktop:release:check` statically verifies the Tauri bundle, updater public
|
||||
key, CSP, capability scopes, command allowlist, query-token ban, generic system
|
||||
notification boundary, CI gate coverage, and secure session token boundary. The
|
||||
@@ -96,7 +101,7 @@ full manual release, security, regression, and Desktop UX checklist lives in
|
||||
## Promotion
|
||||
|
||||
1. Merge feature branches into `dev`.
|
||||
2. Require the shared client/Web and macOS Desktop CI jobs to pass.
|
||||
2. Require the shared client/Web and macOS/Windows Desktop CI jobs to pass.
|
||||
3. Promote the accepted scope from `dev` to `main`.
|
||||
4. Set the release version and complete regression testing on `main`.
|
||||
5. Promote `main` to `release`.
|
||||
@@ -130,11 +135,11 @@ for `localhost`, `127.0.0.1`, or `::1`.
|
||||
The release pipeline must:
|
||||
|
||||
1. build from the accepted release tag and commit;
|
||||
2. build macOS Universal desktop artifacts;
|
||||
3. sign and notarize the macOS app;
|
||||
4. produce updater artifacts and `.sig` files with the updater private key;
|
||||
5. generate `latest.json` and a checksum manifest with `npm run desktop:update-feed:create`;
|
||||
6. verify the feed with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir>`;
|
||||
2. build the Universal macOS app/DMG and Windows x64 NSIS installer from that tag;
|
||||
3. use Apple signing/notarization and Windows Authenticode/RFC 3161 signing by default, or use a checked-in exact-version exception that constrains macOS to ad-hoc signing and Windows to Authenticode-unsigned output;
|
||||
4. produce macOS `.app.tar.gz` and Windows `.nsis.zip` updater artifacts plus their `.sig` files with the shared updater private key;
|
||||
5. generate `DESKTOP-RELEASE-PROVENANCE.json`, one combined `latest.json`, and a checksum manifest;
|
||||
6. verify all macOS and Windows entries with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`;
|
||||
7. upload immutable artifacts first;
|
||||
8. atomically replace `latest.json` last.
|
||||
|
||||
@@ -142,28 +147,86 @@ For Universal macOS artifacts, `latest.json` must provide both
|
||||
`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
|
||||
update package.
|
||||
|
||||
The signed release candidate workflow lives at
|
||||
The same feed must also contain `windows-x86_64`, pointing to the updater-signed
|
||||
NSIS `.nsis.zip` package. The Windows `.exe` installer is distributed next to
|
||||
the updater package but is not used as the updater URL. Tauri updater signing is
|
||||
mandatory in both platform-signed and platform-signing-exception modes.
|
||||
|
||||
The formal release candidate workflow lives at
|
||||
`.github/workflows/desktop-release-candidate.yml`. It must be run from a
|
||||
matching `vX.Y.Z` tag and produces a verified release directory as a GitHub
|
||||
artifact. That artifact is still only a release candidate; the release owner
|
||||
must upload immutable files to the production download origin and replace
|
||||
`latest.json` atomically after validation.
|
||||
|
||||
## Windows Build Readiness
|
||||
Platform signing defaults to `signed`. An exception is allowed only when
|
||||
`frontend/desktop-release-policy.json` names the exact product version and
|
||||
records release-owner approval. The current v0.1.0 exception uses macOS ad-hoc
|
||||
signing and unsigned Windows application/installer binaries. Its artifact names
|
||||
and 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.
|
||||
|
||||
Second-phase Windows work is limited to CI compatibility validation. A
|
||||
`windows-latest` x64 NSIS build may be produced for verification, but it is not
|
||||
a formal deliverable until Windows code signing and release support are
|
||||
approved.
|
||||
### v0.1.0 staged macOS contingency
|
||||
|
||||
Windows validation must cover:
|
||||
The release owner approved one additional v0.1.0 contingency for unavailable
|
||||
hosted Actions capacity. A controlled local macOS host may build the macOS
|
||||
ad-hoc artifacts from the immutable final `v0.1.0` tag and publish them first.
|
||||
That initial 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:
|
||||
|
||||
- import a Base64-encoded PFX from `WINDOWS_CERTIFICATE` using
|
||||
`WINDOWS_CERTIFICATE_PASSWORD`;
|
||||
- configure the imported certificate thumbprint, SHA-256 digest,
|
||||
`WINDOWS_TIMESTAMP_URL`, and RFC 3161 timestamp mode dynamically in CI;
|
||||
- set `REQUIRE_WINDOWS_SIGNING=true` and use the updater signing secrets;
|
||||
- verify the application and installer with `Get-AuthenticodeSignature`,
|
||||
including signer and timestamp certificates;
|
||||
- publish the signed NSIS `.exe`, `.nsis.zip`, and `.nsis.zip.sig` into the
|
||||
combined verified Desktop release directory.
|
||||
|
||||
For an approved exact-version unsigned-platform exception, the Windows job
|
||||
must instead leave the application and installer Authenticode-unsigned, require
|
||||
`Get-AuthenticodeSignature` to return `NotSigned`, append `_UNSIGNED` to the
|
||||
installer and updater artifact names, and still create and verify the updater
|
||||
`.sig`. A certificate secret and timestamp URL are not required in this mode.
|
||||
|
||||
Windows release validation also covers:
|
||||
|
||||
- WebView2 runtime prerequisite behavior;
|
||||
- user-level installer assumptions;
|
||||
- Windows Credential Manager session storage;
|
||||
- path handling and temporary file cleanup;
|
||||
- notification and updater compilation;
|
||||
- future code-signing requirements.
|
||||
- code-signing trust, timestamp validity, and updater signature verification.
|
||||
|
||||
The manual internal Windows validation workflow lives at
|
||||
`.github/workflows/desktop-windows-internal.yml`. It is `workflow_dispatch`
|
||||
@@ -172,8 +235,39 @@ only, runs on `windows-latest`, injects `VITE_BUILD_CHANNEL` and
|
||||
and Desktop safety gates, builds an unsigned NSIS installer with updater
|
||||
artifacts disabled, and uploads the `.exe` plus `SHA256SUMS.txt` as a GitHub
|
||||
Actions artifact. This workflow is for internal compatibility verification
|
||||
only; it must not generate `latest.json`, update feeds, signed release
|
||||
directories, or formal Windows release artifacts.
|
||||
only; it must not generate `latest.json`, update feeds, verified release
|
||||
directories, or formal Windows release artifacts. A successful internal build
|
||||
does not substitute for the tag-only formal workflow, including when that
|
||||
formal workflow uses an approved platform-signing exception.
|
||||
|
||||
The formal workflow always requires these organization settings:
|
||||
|
||||
- secrets: `TAURI_SIGNING_PRIVATE_KEY` and
|
||||
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`;
|
||||
- client default CTMS origin: repository variable `VITE_DESKTOP_SERVER_URL`;
|
||||
- shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the
|
||||
manual workflow input.
|
||||
|
||||
`VITE_DESKTOP_SERVER_URL` must be an HTTPS origin without credentials, path,
|
||||
query, or fragment. Vite embeds it as the Desktop client's first-run default;
|
||||
the runtime source must not contain the production hostname. A user can still
|
||||
override this default in Desktop server settings, where the persisted manual
|
||||
value takes precedence and switching origins clears the existing session and
|
||||
server-scoped client state. Do not reuse this value as
|
||||
`DESKTOP_UPDATE_BASE_URL`: the latter points to immutable updater artifacts,
|
||||
not the CTMS business API.
|
||||
|
||||
The default signed path additionally requires `WINDOWS_CERTIFICATE`,
|
||||
`WINDOWS_CERTIFICATE_PASSWORD`, and `WINDOWS_TIMESTAMP_URL`, plus the Apple
|
||||
credentials documented by the release owner. The v0.1.0 exception does not
|
||||
require those platform certificate settings.
|
||||
|
||||
The checked-in workflow implements the exportable PFX path. Confirm that the
|
||||
organization's certificate policy permits an exportable CI certificate before
|
||||
provisioning it. If the selected CA provides only hardware- or cloud-backed
|
||||
keys, replace the PFX import with a reviewed Tauri `signCommand` integration
|
||||
(for example, the organization's Azure signing provider) while retaining the
|
||||
same Authenticode and timestamp verification gates.
|
||||
|
||||
## Required Checks
|
||||
|
||||
@@ -183,6 +277,7 @@ npm ci
|
||||
npm run version:check
|
||||
export VITE_BUILD_CHANNEL=release
|
||||
export VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
|
||||
export VITE_DESKTOP_SERVER_URL="https://ctms.example.com"
|
||||
npm run release:env:check
|
||||
npm run runtime:check
|
||||
npm run desktop:release:check
|
||||
@@ -193,15 +288,21 @@ npm run build
|
||||
npm run desktop:build:app
|
||||
export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
|
||||
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
|
||||
export REQUIRE_DESKTOP_SIGNING=true
|
||||
export REQUIRE_UPDATER_SIGNING=true
|
||||
export DESKTOP_RELEASE_PLATFORM=macos
|
||||
export DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception
|
||||
export ALLOW_UNSIGNED_PLATFORM_RELEASE=true
|
||||
npm run release:env:check
|
||||
npm run desktop:release-readiness:check
|
||||
npm run desktop:build:macos-release -- --ci
|
||||
npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>
|
||||
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir>
|
||||
npm run desktop:build:macos-unsigned-release -- --ci
|
||||
npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --platform-artifact windows-x86_64=<CTMS.nsis.zip> --include <CTMS.dmg> --include <CTMS-installer.exe> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>
|
||||
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance
|
||||
```
|
||||
|
||||
The Desktop build must run on macOS for the current first-phase target. A signed
|
||||
or notarized public release additionally requires the Apple credentials defined
|
||||
by the release owner. A formal second-phase desktop release also requires the
|
||||
updater signing key; unsigned internal builds are not formal distributions.
|
||||
The macOS and Windows builds run in their native CI jobs and always use the same
|
||||
updater signing key. In the default path, macOS requires Apple
|
||||
signing/notarization credentials and Windows requires the PFX/password and
|
||||
timestamp URL. In the approved v0.1.0 exception, macOS must verify
|
||||
`Signature=adhoc`, Windows must verify `NotSigned`, and both must carry explicit
|
||||
platform-trust warnings. Native artifacts are aggregated only after both jobs
|
||||
and the combined updater feed pass.
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
# ONLYOFFICE 只读预览运行说明
|
||||
# ONLYOFFICE 标准组件运行说明
|
||||
|
||||
当前集成只用于附件和文档版本的 Office 文件只读预览。PDF、图片、另存为和桌面端“打开”仍使用原有链路;第一期不启用编辑、保存回调、历史记录或强制保存。
|
||||
ONLYOFFICE Document Server 是 CTMS 的标准部署组件,用于附件和文档版本预览,以及共享库在线协作编辑。PDF、图片、另存为和桌面端“打开”仍使用各自原有链路。
|
||||
|
||||
## 开发环境启动
|
||||
## 默认安装
|
||||
|
||||
普通 `docker compose up` 不会拉取或启动 Document Server。开发环境需要预览时执行专用启动脚本:
|
||||
执行标准安装即可同时安装 CTMS 与 ONLYOFFICE:
|
||||
|
||||
```bash
|
||||
bash scripts/install.sh dev
|
||||
```
|
||||
|
||||
安装脚本会自动完成以下工作:
|
||||
|
||||
- 首次安装时生成独立的 256-bit 随机 `ONLYOFFICE_JWT_SECRET`,写入根目录 `.env`,且不在终端回显。
|
||||
- 自动生成稳定、按部署环境隔离的实例标识并写入 `.env`。
|
||||
- 将 `.env` 权限设置为 `0600`;该文件已被 Git 忽略。
|
||||
- 默认构建并启动 backend、Document Server 与 Nginx,不再使用可选 Compose Profile。
|
||||
- 将 Document Server 纳入容器状态、后端配置和 HTTP 健康检查。
|
||||
- 后续启动默认复用密钥,防止后端和 Document Server 因签名密钥漂移而无法通信。
|
||||
|
||||
开发环境需要单独重建、验证或主动轮换密钥时,可使用兼容维护脚本:
|
||||
|
||||
```bash
|
||||
bash scripts/onlyoffice-dev-up.sh
|
||||
```
|
||||
|
||||
脚本会自动完成以下工作:
|
||||
|
||||
- 首次启动时生成 256-bit 随机 `ONLYOFFICE_JWT_SECRET`,写入根目录 `.env`,且不在终端回显。
|
||||
- 自动生成稳定的开发实例标识并写入 `.env`。
|
||||
- 将 `.env` 权限设置为 `0600`;该文件已被 Git 忽略。
|
||||
- 使用同一环境重建 backend,并显式启用 `office` Profile 启动 Document Server。
|
||||
- 后续启动默认复用密钥,防止后端和 Document Server 因签名密钥漂移而无法通信。
|
||||
|
||||
需要主动轮换开发密钥时执行:
|
||||
|
||||
```bash
|
||||
bash scripts/onlyoffice-dev-up.sh --rotate-secret
|
||||
```
|
||||
|
||||
@@ -30,7 +32,7 @@ bash scripts/onlyoffice-dev-up.sh --rotate-secret
|
||||
|
||||
## 配置边界
|
||||
|
||||
- `ONLYOFFICE_ENABLED` 默认 `false`。
|
||||
- Compose 安装中的 `ONLYOFFICE_ENABLED` 默认 `true`;安装或更新旧环境时也会自动启用。
|
||||
- `ONLYOFFICE_INTERNAL_URL` 默认 `http://onlyoffice`,只供后端健康探测。
|
||||
- `ONLYOFFICE_STORAGE_BASE_URL` 默认 `http://backend:8000`,只允许后端生成固定的内部文件地址。
|
||||
- `ONLYOFFICE_CONFIG_TTL_SECONDS` 默认 300 秒,允许范围为 60–900 秒。
|
||||
@@ -38,8 +40,8 @@ bash scripts/onlyoffice-dev-up.sh --rotate-secret
|
||||
- 配置响应禁止缓存;JWT、内部文件 URL 和磁盘路径不得进入日志、审计详情或桌面缓存。
|
||||
- `onlyoffice_data`、`onlyoffice_lib` 和 `onlyoffice_logs` 不是 CTMS 业务权威数据,不纳入业务备份或恢复来源。
|
||||
|
||||
## 生产启用前
|
||||
## 生产部署要求
|
||||
|
||||
生产保持功能开关关闭,直至商业许可、批准镜像和真实授权环境回归完成。启用时必须使用生产随机密钥和稳定、唯一的 `ONLYOFFICE_INSTANCE_ID`,并完成 Web、macOS 和 Windows 内测端的真实 DOCX/XLSX/PPTX/WPS/ET/DPS 预览验证。
|
||||
标准生产安装同样包含 ONLYOFFICE,并自动生成独立随机密钥和稳定、唯一的 `ONLYOFFICE_INSTANCE_ID`。正式上线前仍必须确认商业许可或 Community 版本授权边界、批准镜像、容量与备份监控策略,并完成 Web、macOS 和 Windows 内测端的真实 DOCX/XLSX/PPTX/WPS/ET/DPS 预览验证。
|
||||
|
||||
故障回退只关闭 `ONLYOFFICE_ENABLED`;不得影响附件下载、另存为、打开、PDF 或图片预览。Community 与商业版本的授权边界以 [ONLYOFFICE 官方许可说明](https://helpcenter.onlyoffice.com/docs/faq/docs-community.aspx) 为准。
|
||||
故障回退可临时关闭 `ONLYOFFICE_ENABLED`;下一次标准安装或更新会按标准组件策略重新启用。回退不得影响附件下载、另存为、打开、PDF 或图片预览。Community 与商业版本的授权边界以 [ONLYOFFICE 官方许可说明](https://helpcenter.onlyoffice.com/docs/faq/docs-community.aspx) 为准。
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- 文件所有者始终拥有编辑、管理、导出和转让能力;文件管理者始终拥有编辑、管理和导出能力,但不能转让所有权;文件编辑者可以编辑,导出能力由文件“权限设置”开关控制。系统管理员继续保留平台级完整访问能力。
|
||||
- 授予编辑者、管理者或转让所有权时,目标账号必须是当前项目的有效成员,并具备对应项目角色资格;不合格账号在联系人列表中不可选择,后端同时拒绝绕过界面的授权请求。
|
||||
- 文件管理者可开启“允许申请编辑权限”。只读项目成员可从 CTMS 顶栏或 ONLYOFFICE 的“请求编辑”入口提交申请;批准后写入文件级编辑者授权,拒绝或重复处理均有明确状态和审计记录。匿名链接访问者不能申请系统账号权限。
|
||||
- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”。原逾期 AE 和逾期监查问题也先同步为通用通知,再由同一 Feed 返回,不再由网页和桌面布局分别临时汇总。
|
||||
- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”,审批结果作为一次性信息通知申请人。AE、监查问题、文件回执和里程碑时效提醒也由同一 Feed 返回,具体规则见 `docs/project-notifications.md`。
|
||||
- Excel 文件可通过“允许所有协作者添加、删除工作表”控制工作簿结构。关闭时 CTMS 在新的不可变修订中启用工作簿结构保护并推进文件代次;重新开启时恢复文件原有的工作簿保护状态。该开关作用于所有协作者且不影响单元格内容编辑权限。
|
||||
- 仅文档所有者和系统管理员可转让所有权。目标联系人必须具备文件管理者资格;转让后目标联系人升级为文件管理者,原所有者保留管理者身份,所有权变更写入审计。
|
||||
- 文件编辑器“下载为”由文件导出规则判定:所有者和管理者始终允许,编辑者受文件开关控制,并由 CTMS 网页/桌面文件运行时保存到本机且记录下载审计。“另存为…”还会在项目工作区创建独立协作文件,因此额外要求项目级 `collaboration:create` 权限。
|
||||
@@ -49,13 +49,13 @@
|
||||
|
||||
## 本地开发
|
||||
|
||||
执行:
|
||||
标准开发安装会直接启动 ONLYOFFICE:
|
||||
|
||||
```bash
|
||||
bash scripts/onlyoffice-dev-up.sh
|
||||
bash scripts/install.sh dev
|
||||
```
|
||||
|
||||
脚本会生成或复用独立的开发 JWT 密钥,启用 `office` Profile,并验证只读预览及在线协作的后端路由。协作修订保存在现有后端上传卷下的独立 `collaboration/` 目录。
|
||||
安装脚本会生成或复用独立的开发 JWT 密钥和稳定实例标识,默认启动 Document Server,并验证后端配置和 HTTP 健康状态。`bash scripts/onlyoffice-dev-up.sh` 仅用于开发环境单独重建、验证或轮换密钥。协作修订保存在现有后端上传卷下的独立 `collaboration/` 目录。
|
||||
|
||||
关键配置:
|
||||
|
||||
@@ -67,7 +67,7 @@ bash scripts/onlyoffice-dev-up.sh
|
||||
- `ONLYOFFICE_CONFIG_TTL_SECONDS`
|
||||
- `COLLABORATION_MAX_FILE_BYTES`
|
||||
|
||||
生产环境仍默认关闭 ONLYOFFICE;启用前必须按既有发布要求确认镜像许可、JWT 密钥、内部网络、备份与监控。
|
||||
生产标准安装同样包含 ONLYOFFICE;正式上线前必须按既有发布要求确认镜像许可、独立 JWT 密钥、内部网络、容量、备份与监控。
|
||||
|
||||
参考:
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 项目提醒中心
|
||||
|
||||
## 产品边界
|
||||
|
||||
项目提醒是既有业务状态面向明确收件人的可操作投影,不是独立任务、日历或聊天系统。源业务记录和业务审计始终是权威数据;提醒的阅读、桌面投递均不能替代 AE 上报、问题关闭、文件回执、审批或其他业务动作。
|
||||
|
||||
当前范围只包括已认证在线客户端:
|
||||
|
||||
- 网页端和桌面端共用当前项目的通用提醒 Feed。
|
||||
- 顶栏铃铛展示摘要,`/project/notifications` 提供当前提醒、未读和待处理筛选。已经解决或因权限变化失效的提醒不再向客户端返回。
|
||||
- 桌面系统通知是用户主动开启的可选投递渠道,只显示不含项目、文件、受试者或风险详情的通用文案。
|
||||
- 不提供离线提醒、本地业务权威数据、个人自由定时任务、短信、即时通讯或浏览器 Push。
|
||||
- 管理员权限监控告警、登录会话提醒和客户端更新“稍后提醒”不进入业务提醒中心。
|
||||
|
||||
## 状态模型
|
||||
|
||||
`notifications` 是收件人级通用提醒表:
|
||||
|
||||
- `read_at`:用户已经看过提醒。
|
||||
- `resolved_at`:源业务已经完成、关闭、失效,或一次性信息通知已经被阅读。
|
||||
- `requires_action`:区分业务待办和一次性信息通知。
|
||||
- `due_at`:用于展示业务截止时间;业务规则仍以源记录为准。
|
||||
- `source_type`、`source_id`、`source_version` 和 `dedupe_key`:用于来源追踪、幂等同步、阶段升级和自动关闭。
|
||||
|
||||
阅读待处理提醒不会写入 `resolved_at`。业务动作完成后由对应服务即时关闭提醒,定时同步还会对遗漏或权限变化进行对账。一次性信息通知在首次阅读时归档。
|
||||
|
||||
桌面投递状态继续保存在 `desktop_notification_deliveries`,但通过 `notification_id` 关联通用提醒。投递成功只记录 `delivered_at`,不自动标记业务提醒已读或已处理;提醒升级或重新打开时可以重新进入桌面投递队列。
|
||||
|
||||
桌面客户端每 60 秒执行一次在线投递检查:先确认本机系统权限,再读取当前账号的服务端订阅,随后领取待投递提醒、调用系统通知并确认实际成功投递的提醒。任一系统通知调用失败时不会确认该条提醒;网络或投递失败采用指数退避,最长 15 分钟。用户可在“设置 → 通知”查看本次客户端运行期间的最近检查、最近投递和当前链路状态,并主动执行一次真实业务提醒检查。
|
||||
|
||||
## 当前规则
|
||||
|
||||
| 来源 | 收件人 | 触发和升级 | 自动关闭 |
|
||||
|---|---|---|---|
|
||||
| 协作编辑申请 | 文件所有者、文件管理者 | 申请创建 | 申请批准或拒绝 |
|
||||
| 协作申请结果 | 申请人 | 申请批准或拒绝 | 申请人阅读后归档 |
|
||||
| 文件分发与回执 | 指定用户、指定角色、中心联系人 | 新分发;3 天内到期;逾期 | 用户完成接收回执、分发关闭或权限失效 |
|
||||
| AE 上报时效 | 具备 AE 读取权限且在中心范围内的项目成员 | 3 天内到期;逾期;按数量增加重新提醒 | 数量归零或权限失效 |
|
||||
| 监查问题整改 | 具备监查问题读取权限且在中心范围内的项目成员 | 3 天内到期;逾期;按数量增加重新提醒 | 数量归零或权限失效 |
|
||||
| 项目里程碑 | 里程碑负责人 | 7 天内到期;逾期;日期或状态变化重新提醒 | 完成、负责人变化或日期移出提醒窗口 |
|
||||
| 受试者访视窗口 | 具备访视更新权限的有效项目成员;CRA 仅限负责中心 | 窗口开始前 3 天、窗口进行中;错过窗口后升级 | 录入实际访视、取消访视、受试者结束、中心停用或权限/中心范围失效 |
|
||||
|
||||
所有规则都要求当前有效项目成员和对应业务权限。系统管理员不会仅因平台权限而批量接收所有项目提醒;需要作为有效项目成员或明确业务收件人进入提醒范围。
|
||||
|
||||
日期型 AE、里程碑和访视窗口规则按 `Asia/Shanghai` 业务日计算,带时区的截止时间统一按 UTC 存储和比较。若未来引入项目级时区,应由项目配置替代固定业务时区,不能使用浏览器本地时区驱动服务端规则。
|
||||
|
||||
访视提醒按访视记录生成并可跳转到对应受试者详情,但提醒标题和正文不包含受试者编号、姓名、中心名称或其他可识别信息。收件人资格以 `visits:update` 及其前置权限为准,避免仅具备只读权限的 PV、QA、CTA 等角色被动接收受试者访视提醒。
|
||||
|
||||
## 同步与诊断
|
||||
|
||||
服务端启动提醒同步任务,默认每 300 秒对所有有效项目成员执行幂等同步;间隔通过 `NOTIFICATION_SYNC_INTERVAL_SECONDS` 配置,允许 60–3600 秒。PostgreSQL advisory lock 防止多个后端进程重复执行全量任务。
|
||||
|
||||
用户读取 Feed 时还会执行一次当前用户轻量对账,用于弥补短时任务失败。单个成员同步失败不会中断其他成员;失败写入 `ctms.notifications` 日志。提醒创建、更新和自动关闭本身不替代源业务审计。
|
||||
|
||||
## 桌面隐私边界
|
||||
|
||||
系统通知固定为:
|
||||
|
||||
- 标题:`CTMS 待办提醒`
|
||||
- 正文:`有新的业务提醒待查看`
|
||||
|
||||
系统通知 API 不接受动态标题或正文。项目名、文件名、版本、受试者、AE、监查问题等详情只允许在完成 token 校验和项目权限确认后的应用内 Feed 中展示;访视提醒即使在应用内 Feed 也不直接显示受试者标识,只通过受控详情路径访问。
|
||||
|
||||
## 系统通知授权边界
|
||||
|
||||
系统权限和服务端订阅是两个独立条件:
|
||||
|
||||
- 首次开启时,客户端先请求操作系统授权;只有操作系统返回已授权,才开启当前账号的服务端订阅。
|
||||
- 用户在操作系统中撤销权限后,服务端订阅可以仍然保持开启,但客户端不会领取提醒,设置页会明确显示“系统权限已拒绝”。
|
||||
- macOS 或 Windows 已拒绝权限时,系统通常不会再次展示授权框。设置页分别引导用户前往“系统设置 → 通知 → CTMS”或“设置 → 系统 → 通知 → CTMS”,返回后通过“重新检测权限”恢复链路。
|
||||
- “发送测试通知”只验证本机通知能力,不访问业务提醒队列;“立即检查业务提醒”会走服务端订阅、领取、系统投递和确认的真实路径。
|
||||
- CTMS 不申请打开任意 URL、执行系统命令或读取操作系统通知内容的额外 Tauri 权限。授权引导仅展示设置路径,避免为便利入口扩大桌面原生能力边界。
|
||||
@@ -2,6 +2,8 @@
|
||||
VITE_RUNTIME_ENV=production
|
||||
VITE_ALLOW_INSECURE_DEV_LOGIN=false
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
# Default CTMS origin embedded in Desktop builds; users can override it in Desktop settings.
|
||||
VITE_DESKTOP_SERVER_URL=https://ctms.example.com
|
||||
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
|
||||
VITE_HMR_CLIENT_PORT=
|
||||
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
CTMS DESKTOP PLATFORM SIGNING WARNING
|
||||
|
||||
This release is distributed under the version-scoped v0.1.0 platform-signing exception.
|
||||
|
||||
- The macOS application uses an ad-hoc signature and is not Apple-notarized.
|
||||
- The Windows application and installer have no Authenticode signature or RFC 3161 timestamp.
|
||||
- macOS Gatekeeper and Windows SmartScreen warnings are expected.
|
||||
- Tauri updater artifacts remain cryptographically signed and must pass updater feed verification.
|
||||
- Platform availability may be staged; production latest.json remains withheld until macOS and Windows artifacts from the same tag pass combined verification.
|
||||
- Install only after verifying the release tag, source commit, SHA256SUMS.txt, and DESKTOP-RELEASE-PROVENANCE.json through a trusted channel.
|
||||
|
||||
This exception does not apply to later versions unless they are separately approved in desktop-release-policy.json.
|
||||
@@ -0,0 +1,9 @@
|
||||
CTMS v0.1.0 WINDOWS RELEASE PENDING
|
||||
|
||||
This GitHub Release is the approved first stage of the v0.1.0 Desktop distribution.
|
||||
|
||||
- macOS Universal artifacts are available and use an ad-hoc platform signature.
|
||||
- Windows x64 NSIS artifacts are not yet available.
|
||||
- Windows must later be built from the same immutable v0.1.0 tag and Git commit.
|
||||
- Production latest.json is intentionally withheld until Windows NotSigned artifacts and the combined updater feed pass verification.
|
||||
- Do not interpret this staged release as a complete cross-platform automatic-update release.
|
||||
Generated
+316
-187
@@ -1001,9 +1001,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz",
|
||||
"integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
||||
"integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1015,9 +1015,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz",
|
||||
"integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
|
||||
"integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1028,10 +1028,24 @@
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
|
||||
"integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz",
|
||||
"integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
|
||||
"integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1043,9 +1057,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz",
|
||||
"integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
|
||||
"integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1057,9 +1071,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz",
|
||||
"integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
|
||||
"integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1071,13 +1085,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz",
|
||||
"integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
|
||||
"integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1085,13 +1102,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz",
|
||||
"integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
|
||||
"integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1099,13 +1119,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
|
||||
"integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1113,11 +1136,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.5",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
|
||||
"integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1125,13 +1153,33 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
|
||||
"integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
|
||||
"integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1139,13 +1187,33 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
|
||||
"integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
|
||||
"integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1153,13 +1221,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
|
||||
"integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1167,13 +1238,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz",
|
||||
"integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
|
||||
"integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1181,13 +1255,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
|
||||
"integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1195,13 +1272,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
|
||||
"integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1209,9 +1289,26 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz",
|
||||
"integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
|
||||
"integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
|
||||
"integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1219,13 +1316,13 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz",
|
||||
"integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
|
||||
"integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1237,9 +1334,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz",
|
||||
"integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
|
||||
"integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1251,9 +1348,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz",
|
||||
"integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
|
||||
"integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1265,9 +1362,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
|
||||
"integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1279,9 +1376,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz",
|
||||
"integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
|
||||
"integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1589,7 +1686,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1634,15 +1733,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
@@ -1651,13 +1750,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
@@ -1688,9 +1787,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
||||
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1701,13 +1800,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
@@ -1716,13 +1815,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1731,9 +1830,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1744,13 +1843,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
@@ -2002,12 +2101,40 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@@ -2181,7 +2308,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2239,21 +2365,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
"zrender": "6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/editorconfig": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
|
||||
@@ -2432,7 +2552,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2467,14 +2589,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -2591,7 +2715,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -2781,11 +2907,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
@@ -2876,7 +3006,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
@@ -2887,7 +3016,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3030,9 +3161,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3063,7 +3194,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -3080,7 +3213,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -3096,8 +3229,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -3110,11 +3248,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.5",
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
@@ -3124,45 +3264,34 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.5",
|
||||
"@rollup/rollup-android-arm64": "4.53.5",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.5",
|
||||
"@rollup/rollup-darwin-x64": "4.53.5",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.5",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.5",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.5",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.5",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.5",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.5",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.5",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.5",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.5",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.5",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.5",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.5",
|
||||
"@rollup/rollup-android-arm-eabi": "4.62.2",
|
||||
"@rollup/rollup-android-arm64": "4.62.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.62.2",
|
||||
"@rollup/rollup-darwin-x64": "4.62.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.62.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.62.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.62.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.62.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.62.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.62.2",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.62.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.62.2",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.62.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.62.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.62.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.62.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.62.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.62.2",
|
||||
"@rollup/rollup-openbsd-x64": "4.62.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.62.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.62.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.62.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.62.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.62.2",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz",
|
||||
"integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
@@ -3498,6 +3627,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"devOptional": true,
|
||||
@@ -3516,13 +3651,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -3614,20 +3749,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/expect": "3.2.7",
|
||||
"@vitest/mocker": "3.2.7",
|
||||
"@vitest/pretty-format": "^3.2.7",
|
||||
"@vitest/runner": "3.2.7",
|
||||
"@vitest/snapshot": "3.2.7",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
@@ -3657,8 +3792,8 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"@vitest/browser": "3.2.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
@@ -3976,9 +4111,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4015,19 +4150,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"desktop:build": "tauri build",
|
||||
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
|
||||
"desktop:build:macos-release": "tauri build --target universal-apple-darwin --bundles app,dmg",
|
||||
"desktop:build:macos-unsigned-release": "tauri build --target universal-apple-darwin --bundles app,dmg --config '{\"bundle\":{\"macOS\":{\"signingIdentity\":\"-\"}}}'",
|
||||
"desktop:build:windows-release": "tauri build --bundles nsis",
|
||||
"desktop:release-policy:check": "node scripts/resolve-desktop-release-policy.mjs",
|
||||
"desktop:release-provenance:create": "node scripts/create-desktop-release-provenance.mjs",
|
||||
"desktop:update-feed:create": "node scripts/create-desktop-update-feed.mjs",
|
||||
"desktop:bundle:dmg": "tauri build --bundles dmg",
|
||||
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const args = process.argv.slice(2);
|
||||
const value = (name) => {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
};
|
||||
|
||||
const policy = await loadDesktopReleasePolicy();
|
||||
const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy);
|
||||
const tag = value("--tag") || process.env.GITHUB_REF_NAME;
|
||||
const commit = value("--commit") || process.env.GITHUB_SHA;
|
||||
const macosSigning = value("--macos-signing");
|
||||
const windowsSigning = value("--windows-signing");
|
||||
const releaseStage = value("--stage") || "complete";
|
||||
const outputPath = resolve(
|
||||
frontendDir,
|
||||
value("--output") || "src-tauri/target/desktop-release-feed/DESKTOP-RELEASE-PROVENANCE.json",
|
||||
);
|
||||
|
||||
const failures = [];
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) failures.push(message);
|
||||
};
|
||||
|
||||
assert(tag === `v${packageInfo.version}`, `Release provenance tag must be v${packageInfo.version}.`);
|
||||
assert(/^[0-9a-f]{40}$/i.test(commit || ""), "Release provenance commit must be a full 40-character SHA.");
|
||||
assert(macosSigning === resolution.macosSigning, `macOS signing mode must be ${resolution.macosSigning}.`);
|
||||
assert(windowsSigning === resolution.windowsSigning, `Windows signing mode must be ${resolution.windowsSigning}.`);
|
||||
assert(["complete", "macos-first"].includes(releaseStage), "Release provenance stage must be complete or macos-first.");
|
||||
if (releaseStage === "macos-first") {
|
||||
assert(
|
||||
resolution.macosLocalExactTagFallback === true && resolution.windowsDelivery === "deferred-same-tag",
|
||||
`v${packageInfo.version} does not approve a staged local macOS-first release.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Desktop release provenance creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
}
|
||||
|
||||
const provenance = {
|
||||
schemaVersion: 1,
|
||||
product: "CTMS Desktop",
|
||||
version: packageInfo.version,
|
||||
tag,
|
||||
commit,
|
||||
platformSigningMode: resolution.platformSigningMode,
|
||||
platformSigning: {
|
||||
macos: macosSigning,
|
||||
windows: windowsSigning,
|
||||
},
|
||||
updaterSigning: "required-and-verified",
|
||||
artifactLabel: resolution.artifactLabel,
|
||||
distribution: resolution.distribution,
|
||||
stage: releaseStage,
|
||||
platformAvailability:
|
||||
releaseStage === "macos-first"
|
||||
? { macos: "available", windows: "pending-same-tag" }
|
||||
: { macos: "available", windows: "available" },
|
||||
updaterFeedActivation:
|
||||
releaseStage === "macos-first" ? "withheld-pending-combined-verification" : "combined-platform-verification-required",
|
||||
warnings: resolution.warningRequired
|
||||
? [
|
||||
"macOS is ad-hoc signed and not Apple-notarized; Gatekeeper warnings are expected.",
|
||||
"Windows is not Authenticode-signed or RFC 3161 timestamped; SmartScreen warnings are expected.",
|
||||
"Verify the release tag, commit, updater signatures, and SHA256SUMS.txt through a trusted channel before installation.",
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, `${JSON.stringify(provenance, null, 2)}\n`);
|
||||
console.log(`Desktop release provenance created at ${outputPath}`);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
@@ -25,7 +25,7 @@ const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const artifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT;
|
||||
const macosArtifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT;
|
||||
const outputDir = resolve(
|
||||
frontendDir,
|
||||
value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed",
|
||||
@@ -34,18 +34,40 @@ const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
|
||||
const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString();
|
||||
const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES;
|
||||
const includes = values("--include").map((path) => resolve(frontendDir, path));
|
||||
const platformArtifactSpecs = values("--platform-artifact");
|
||||
const platformPattern = /^(?:darwin|windows|linux)-(?:x86_64|aarch64|i686|armv7)$/;
|
||||
|
||||
assert(Boolean(artifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required.");
|
||||
assert(Boolean(macosArtifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required for the Universal macOS updater artifact.");
|
||||
assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required.");
|
||||
|
||||
const resolvedArtifactPath = artifactPath ? resolve(frontendDir, artifactPath) : undefined;
|
||||
const artifactName = resolvedArtifactPath ? basename(resolvedArtifactPath) : undefined;
|
||||
const signaturePath = resolvedArtifactPath ? `${resolvedArtifactPath}.sig` : undefined;
|
||||
const uploadFiles = [];
|
||||
const platformArtifacts = new Map();
|
||||
if (macosArtifactPath) {
|
||||
const resolvedMacosArtifact = resolve(frontendDir, macosArtifactPath);
|
||||
platformArtifacts.set("darwin-aarch64", resolvedMacosArtifact);
|
||||
platformArtifacts.set("darwin-x86_64", resolvedMacosArtifact);
|
||||
}
|
||||
|
||||
for (const spec of platformArtifactSpecs) {
|
||||
const separator = spec.indexOf("=");
|
||||
if (separator <= 0 || separator === spec.length - 1) {
|
||||
fail(`--platform-artifact must use <os>-<arch>=<path>: ${spec}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const platform = spec.slice(0, separator);
|
||||
const path = spec.slice(separator + 1);
|
||||
assert(platformPattern.test(platform), `Unsupported updater platform key: ${platform}`);
|
||||
assert(!platformArtifacts.has(platform), `Updater platform is configured more than once: ${platform}`);
|
||||
if (platformPattern.test(platform) && !platformArtifacts.has(platform)) {
|
||||
platformArtifacts.set(platform, resolve(frontendDir, path));
|
||||
}
|
||||
}
|
||||
|
||||
const readRequiredFile = async (path, description) => {
|
||||
try {
|
||||
return await readFile(path);
|
||||
const data = await readFile(path);
|
||||
assert(data.length > 0, `${description} must not be empty: ${path}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
fail(`${description} cannot be read: ${path} (${error.message})`);
|
||||
return undefined;
|
||||
@@ -72,20 +94,16 @@ const normalizedBaseUrl = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const copyIntoOutput = async (sourcePath) => {
|
||||
const destination = resolve(outputDir, basename(sourcePath));
|
||||
if (sourcePath !== destination) {
|
||||
await copyFile(sourcePath, destination);
|
||||
}
|
||||
return destination;
|
||||
};
|
||||
|
||||
if (resolvedArtifactPath && signaturePath) {
|
||||
await readRequiredFile(resolvedArtifactPath, "Updater artifact");
|
||||
const artifactSignatures = new Map();
|
||||
const uniqueArtifactPaths = [...new Set(platformArtifacts.values())];
|
||||
for (const artifactPath of uniqueArtifactPaths) {
|
||||
const signaturePath = `${artifactPath}.sig`;
|
||||
await readRequiredFile(artifactPath, "Updater artifact");
|
||||
const signature = await readRequiredFile(signaturePath, "Updater artifact signature");
|
||||
if (signature) {
|
||||
const signatureText = signature.toString("utf8").trim();
|
||||
assert(signatureText.length > 80, "Updater artifact signature is unexpectedly short.");
|
||||
assert(signatureText.length > 80, `Updater artifact signature is unexpectedly short: ${signaturePath}`);
|
||||
artifactSignatures.set(artifactPath, signatureText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,56 +116,75 @@ for (const includePath of includes) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [platform, artifactPath] of platformArtifacts) {
|
||||
if (platform.startsWith("darwin-")) {
|
||||
assert(artifactPath.endsWith(".app.tar.gz"), `${platform} must use a macOS .app.tar.gz updater artifact.`);
|
||||
}
|
||||
if (platform === "windows-x86_64") {
|
||||
assert(artifactPath.endsWith(".nsis.zip"), "windows-x86_64 must use an NSIS .nsis.zip updater artifact.");
|
||||
}
|
||||
}
|
||||
|
||||
const uploadSources = [
|
||||
...uniqueArtifactPaths.flatMap((path) => [path, `${path}.sig`]),
|
||||
...includes,
|
||||
];
|
||||
const sourceByName = new Map();
|
||||
for (const sourcePath of uploadSources) {
|
||||
const name = basename(sourcePath);
|
||||
const existing = sourceByName.get(name);
|
||||
assert(!existing || existing === sourcePath, `Release files must have unique basenames: ${name}`);
|
||||
sourceByName.set(name, sourcePath);
|
||||
}
|
||||
|
||||
const baseUrl = normalizedBaseUrl();
|
||||
|
||||
if (failures.length === 0 && resolvedArtifactPath && signaturePath && artifactName && baseUrl) {
|
||||
if (failures.length === 0 && baseUrl) {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const artifactUrl = new URL(artifactName, baseUrl).toString();
|
||||
assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json.");
|
||||
assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters.");
|
||||
const platforms = {};
|
||||
for (const [platform, artifactPath] of platformArtifacts) {
|
||||
const artifactUrl = new URL(basename(artifactPath), baseUrl).toString();
|
||||
assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json.");
|
||||
assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters.");
|
||||
platforms[platform] = {
|
||||
signature: artifactSignatures.get(artifactPath),
|
||||
url: artifactUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const signature = (await readFile(signaturePath, "utf8")).trim();
|
||||
const latest = {
|
||||
version: packageInfo.version,
|
||||
pub_date: pubDate,
|
||||
platforms: {
|
||||
"darwin-aarch64": {
|
||||
signature,
|
||||
url: artifactUrl,
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
signature,
|
||||
url: artifactUrl,
|
||||
},
|
||||
},
|
||||
platforms,
|
||||
};
|
||||
|
||||
if (notes) {
|
||||
latest.notes = notes;
|
||||
}
|
||||
|
||||
uploadFiles.push(await copyIntoOutput(resolvedArtifactPath));
|
||||
uploadFiles.push(await copyIntoOutput(signaturePath));
|
||||
for (const includePath of includes) {
|
||||
uploadFiles.push(await copyIntoOutput(includePath));
|
||||
const copiedFiles = [];
|
||||
for (const sourcePath of sourceByName.values()) {
|
||||
const destination = resolve(outputDir, basename(sourcePath));
|
||||
if (sourcePath !== destination) {
|
||||
await copyFile(sourcePath, destination);
|
||||
}
|
||||
copiedFiles.push(destination);
|
||||
}
|
||||
|
||||
const latestPath = resolve(outputDir, "latest.json");
|
||||
await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`);
|
||||
uploadFiles.push(latestPath);
|
||||
copiedFiles.push(latestPath);
|
||||
|
||||
const uniqueFiles = [...new Map(uploadFiles.map((path) => [basename(path), path])).values()];
|
||||
const checksumLines = [];
|
||||
for (const filePath of uniqueFiles) {
|
||||
for (const filePath of copiedFiles) {
|
||||
checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`);
|
||||
}
|
||||
const checksumPath = resolve(outputDir, "SHA256SUMS.txt");
|
||||
await writeFile(checksumPath, `${checksumLines.join("\n")}\n`);
|
||||
|
||||
console.log(`Desktop update feed created in ${outputDir}`);
|
||||
console.log(` - ${uniqueFiles.map((path) => basename(path)).join("\n - ")}`);
|
||||
console.log(` - SHA256SUMS.txt`);
|
||||
console.log(` - ${copiedFiles.map((path) => basename(path)).join("\n - ")}`);
|
||||
console.log(" - SHA256SUMS.txt");
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { appendFile, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const args = process.argv.slice(2);
|
||||
const value = (name) => {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
};
|
||||
|
||||
const policy = await loadDesktopReleasePolicy();
|
||||
const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy);
|
||||
const expectedTag = `v${packageInfo.version}`;
|
||||
|
||||
if (args.includes("--require-tag")) {
|
||||
if (process.env.GITHUB_REF_TYPE !== "tag" || process.env.GITHUB_REF_NAME !== expectedTag) {
|
||||
throw new Error(
|
||||
`Desktop release policy must be resolved from tag ${expectedTag}; found ${process.env.GITHUB_REF_TYPE || "<missing>"} ${process.env.GITHUB_REF_NAME || "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const outputs = {
|
||||
version: resolution.version,
|
||||
platform_signing_mode: resolution.platformSigningMode,
|
||||
macos_signing: resolution.macosSigning,
|
||||
windows_signing: resolution.windowsSigning,
|
||||
artifact_label: resolution.artifactLabel,
|
||||
distribution: resolution.distribution,
|
||||
warning_required: String(resolution.warningRequired),
|
||||
updater_signing_required: String(policy.updaterSigningRequired),
|
||||
};
|
||||
|
||||
const githubOutput = value("--github-output");
|
||||
if (githubOutput) {
|
||||
await appendFile(githubOutput, `${Object.entries(outputs).map(([key, output]) => `${key}=${output}`).join("\n")}\n`);
|
||||
}
|
||||
|
||||
console.log("Desktop release policy check passed.");
|
||||
console.log(` version: ${resolution.version}`);
|
||||
console.log(` platform signing mode: ${resolution.platformSigningMode}`);
|
||||
console.log(` macOS: ${resolution.macosSigning}`);
|
||||
console.log(` Windows: ${resolution.windowsSigning}`);
|
||||
console.log(" updater signing: required");
|
||||
@@ -2,22 +2,27 @@ import { execFileSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const rootDir = resolve(frontendDir, "..");
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const desktopReleasePolicy = await loadDesktopReleasePolicy();
|
||||
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
|
||||
const failures = [];
|
||||
|
||||
const env = process.env;
|
||||
const fullShaPattern = /^[0-9a-f]{40}$/i;
|
||||
const expectedTag = `v${packageInfo.version}`;
|
||||
const requiredSecretLikeEnv = [
|
||||
"TAURI_SIGNING_PRIVATE_KEY",
|
||||
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
|
||||
"APPLE_ID",
|
||||
"APPLE_PASSWORD",
|
||||
"APPLE_TEAM_ID",
|
||||
];
|
||||
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
|
||||
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
|
||||
const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true";
|
||||
const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true";
|
||||
const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE;
|
||||
const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM;
|
||||
const requiredUpdaterEnv = ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"];
|
||||
const requiredMacosEnv = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID"];
|
||||
const requiredWindowsEnv = ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD"];
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
@@ -40,7 +45,7 @@ const gitMaybe = (args) => {
|
||||
};
|
||||
|
||||
const requireEnv = (name) => {
|
||||
assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
|
||||
assert(Boolean(env[name]), `${name} must be configured for desktop release readiness.`);
|
||||
};
|
||||
|
||||
const validateBaseUrl = () => {
|
||||
@@ -65,6 +70,43 @@ const validateBaseUrl = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const validateClientServerUrl = () => {
|
||||
const raw = env.VITE_DESKTOP_SERVER_URL;
|
||||
requireEnv("VITE_DESKTOP_SERVER_URL");
|
||||
if (!raw) return;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch (error) {
|
||||
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
|
||||
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
|
||||
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
|
||||
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
|
||||
};
|
||||
|
||||
const validateWindowsTimestampUrl = () => {
|
||||
const raw = env.WINDOWS_TIMESTAMP_URL;
|
||||
requireEnv("WINDOWS_TIMESTAMP_URL");
|
||||
if (!raw) return;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch (error) {
|
||||
fail(`WINDOWS_TIMESTAMP_URL is invalid: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(["http:", "https:"].includes(url.protocol), "WINDOWS_TIMESTAMP_URL must use HTTP or HTTPS.");
|
||||
assert(url.username === "" && url.password === "", "WINDOWS_TIMESTAMP_URL must not include credentials.");
|
||||
assert(!/[?&]token=/i.test(url.search), "WINDOWS_TIMESTAMP_URL must not include token query parameters.");
|
||||
};
|
||||
|
||||
const headSha = gitMaybe(["rev-parse", "HEAD"]);
|
||||
const exactTag = gitMaybe(["describe", "--tags", "--exact-match", "HEAD"]);
|
||||
const status = gitMaybe(["status", "--porcelain"]);
|
||||
@@ -79,19 +121,78 @@ if (headSha && env.VITE_BUILD_COMMIT) {
|
||||
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
|
||||
}
|
||||
|
||||
for (const name of requiredSecretLikeEnv) {
|
||||
assert(
|
||||
!(requiresMacosSigning && requiresWindowsSigning),
|
||||
"macOS and Windows signing readiness must be checked in their native jobs.",
|
||||
);
|
||||
assert(
|
||||
desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows",
|
||||
"DESKTOP_RELEASE_PLATFORM must be macos or windows.",
|
||||
);
|
||||
assert(
|
||||
desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode,
|
||||
`DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`,
|
||||
);
|
||||
assert(
|
||||
desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning,
|
||||
"Release readiness requires REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.",
|
||||
);
|
||||
|
||||
for (const name of requiredUpdaterEnv) {
|
||||
requireEnv(name);
|
||||
}
|
||||
|
||||
assert(
|
||||
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
|
||||
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
|
||||
);
|
||||
if (env.APPLE_CERTIFICATE) {
|
||||
requireEnv("APPLE_CERTIFICATE_PASSWORD");
|
||||
if (desktopPlatformSigningMode === "signed") {
|
||||
assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE.");
|
||||
assert(
|
||||
(desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) ||
|
||||
(desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning),
|
||||
"Signed readiness must enable only the matching native platform signing requirement.",
|
||||
);
|
||||
}
|
||||
|
||||
if (desktopPlatformSigningMode === "unsigned-exception") {
|
||||
assert(
|
||||
desktopReleaseResolution.platformSigningMode === "unsigned-exception",
|
||||
`v${packageInfo.version} has no approved unsigned platform release exception.`,
|
||||
);
|
||||
assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true.");
|
||||
assert(
|
||||
!requiresMacosSigning && !requiresWindowsSigning,
|
||||
"Unsigned platform readiness must not claim Apple or Windows platform signing.",
|
||||
);
|
||||
if (desktopReleasePlatform === "macos") {
|
||||
assert(process.platform === "darwin", "The macOS ad-hoc readiness check must run on macOS.");
|
||||
}
|
||||
if (desktopReleasePlatform === "windows") {
|
||||
assert(process.platform === "win32", "The unsigned Windows readiness check must run on Windows.");
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresMacosSigning) {
|
||||
assert(process.platform === "darwin", "Signed macOS desktop release readiness must run on macOS.");
|
||||
for (const name of requiredMacosEnv) {
|
||||
requireEnv(name);
|
||||
}
|
||||
assert(
|
||||
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
|
||||
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
|
||||
);
|
||||
if (env.APPLE_CERTIFICATE) {
|
||||
requireEnv("APPLE_CERTIFICATE_PASSWORD");
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresWindowsSigning) {
|
||||
assert(process.platform === "win32", "Signed Windows desktop release readiness must run on Windows.");
|
||||
for (const name of requiredWindowsEnv) {
|
||||
requireEnv(name);
|
||||
}
|
||||
validateWindowsTimestampUrl();
|
||||
}
|
||||
|
||||
validateBaseUrl();
|
||||
validateClientServerUrl();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
|
||||
@@ -62,7 +62,7 @@ const verifyTauriConfig = async () => {
|
||||
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
|
||||
assert(
|
||||
tauriConfig.bundle?.createUpdaterArtifacts === true,
|
||||
"Tauri must create updater artifacts for signed desktop release builds.",
|
||||
"Tauri must create signed updater artifacts for formal desktop release builds.",
|
||||
);
|
||||
assert(
|
||||
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
|
||||
@@ -362,8 +362,8 @@ const verifySourceSafety = async () => {
|
||||
|
||||
const verifyNotificationBoundary = async () => {
|
||||
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
|
||||
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
|
||||
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
|
||||
assert(source.includes('title: "CTMS 待办提醒"'), "Desktop notification title must stay generic.");
|
||||
assert(source.includes('body: "有新的业务提醒待查看"'), "Desktop notification body must stay generic.");
|
||||
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
|
||||
};
|
||||
|
||||
@@ -387,11 +387,28 @@ const verifyUpdaterBoundary = async () => {
|
||||
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
|
||||
};
|
||||
|
||||
const verifyDesktopServerConfigurationBoundary = async () => {
|
||||
const source = await readFile(resolve(sourceDir, "runtime/desktopServerConfig.ts"), "utf8");
|
||||
assert(
|
||||
source.includes("import.meta.env.VITE_DESKTOP_SERVER_URL"),
|
||||
"Desktop server defaults must come from VITE_DESKTOP_SERVER_URL.",
|
||||
);
|
||||
assert(!source.includes("ctms.huapont.cn"), "The production CTMS origin must not be hard-coded in Desktop runtime source.");
|
||||
assert(
|
||||
source.includes("getStoredDesktopServerUrl() || getDefaultDesktopServerUrl()"),
|
||||
"A manually stored Desktop server URL must override the build-time default.",
|
||||
);
|
||||
};
|
||||
|
||||
const verifyWorkflowGates = async () => {
|
||||
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
|
||||
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
|
||||
const requiredScripts = [
|
||||
"desktop:build:macos-release",
|
||||
"desktop:build:macos-unsigned-release",
|
||||
"desktop:build:windows-release",
|
||||
"desktop:release-policy:check",
|
||||
"desktop:release-provenance:create",
|
||||
"desktop:update-feed:create",
|
||||
"desktop:update-feed:check",
|
||||
"desktop:release-readiness:check",
|
||||
@@ -402,6 +419,47 @@ 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",
|
||||
@@ -420,19 +478,46 @@ const verifyWorkflowGates = async () => {
|
||||
}
|
||||
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
|
||||
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
|
||||
assert(workflow.includes("VITE_DESKTOP_SERVER_URL"), "Client quality gates workflow must inject VITE_DESKTOP_SERVER_URL.");
|
||||
assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs.");
|
||||
|
||||
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
|
||||
const requiredReleaseWorkflowTokens = [
|
||||
"runs-on: macos-latest",
|
||||
"runs-on: windows-latest",
|
||||
"REQUIRE_DESKTOP_SIGNING",
|
||||
"REQUIRE_WINDOWS_SIGNING",
|
||||
"REQUIRE_UPDATER_SIGNING",
|
||||
"ALLOW_UNSIGNED_PLATFORM_RELEASE",
|
||||
"DESKTOP_PLATFORM_SIGNING_MODE",
|
||||
"desktop:release-policy:check",
|
||||
"desktop:build:macos-unsigned-release",
|
||||
"Signature=adhoc",
|
||||
'"NotSigned"',
|
||||
"UNSIGNED-PLATFORM",
|
||||
"DESKTOP-RELEASE-PROVENANCE.json",
|
||||
"--require-provenance",
|
||||
"TAURI_SIGNING_PRIVATE_KEY",
|
||||
"VITE_DESKTOP_SERVER_URL",
|
||||
"APPLE_ID",
|
||||
"APPLE_PASSWORD",
|
||||
"APPLE_TEAM_ID",
|
||||
"WINDOWS_CERTIFICATE",
|
||||
"WINDOWS_CERTIFICATE_PASSWORD",
|
||||
"WINDOWS_TIMESTAMP_URL",
|
||||
"npm audit",
|
||||
"npm run desktop:build:macos-release",
|
||||
"npm run desktop:build:windows-release",
|
||||
"Get-AuthenticodeSignature",
|
||||
"EnhancedKeyUsageList",
|
||||
"TimeStamperCertificate",
|
||||
".nsis.zip",
|
||||
"npm run desktop:update-feed:create",
|
||||
"--platform-artifact",
|
||||
"npm run desktop:update-feed:check",
|
||||
"--require-platform windows-x86_64",
|
||||
"npm run desktop:release-readiness:check",
|
||||
"actions/download-artifact",
|
||||
"actions/upload-artifact",
|
||||
];
|
||||
|
||||
@@ -447,6 +532,7 @@ const verifyWorkflowGates = async () => {
|
||||
"runs-on: windows-latest",
|
||||
"VITE_BUILD_CHANNEL",
|
||||
"VITE_BUILD_COMMIT",
|
||||
"VITE_DESKTOP_SERVER_URL",
|
||||
"npm run release:env:check",
|
||||
"npm run version:check",
|
||||
"npm run runtime:check",
|
||||
@@ -505,6 +591,7 @@ await verifySourceSafety();
|
||||
await verifyNotificationBoundary();
|
||||
await verifySessionBoundary();
|
||||
await verifyUpdaterBoundary();
|
||||
await verifyDesktopServerConfigurationBoundary();
|
||||
await verifyOnlyOfficeBoundary();
|
||||
await verifyWorkflowGates();
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@ import { access, readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const desktopReleasePolicy = await loadDesktopReleasePolicy();
|
||||
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
|
||||
const failures = [];
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -12,6 +15,8 @@ const optionValue = (name) => {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
};
|
||||
const optionValues = (name) =>
|
||||
args.reduce((result, argument, index) => (argument === name && args[index + 1] ? [...result, args[index + 1]] : result), []);
|
||||
|
||||
const feedPath = resolve(
|
||||
frontendDir,
|
||||
@@ -19,6 +24,17 @@ const feedPath = resolve(
|
||||
);
|
||||
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
|
||||
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
|
||||
const requiresProvenance = args.includes("--require-provenance");
|
||||
const provenancePath =
|
||||
optionValue("--provenance") ||
|
||||
process.env.DESKTOP_RELEASE_PROVENANCE ||
|
||||
(artifactDir ? resolve(artifactDir, "DESKTOP-RELEASE-PROVENANCE.json") : undefined);
|
||||
const requiredPlatforms = new Set([
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
...optionValues("--require-platform"),
|
||||
]);
|
||||
const platformPattern = /^(?:darwin|windows|linux)-(?:x86_64|aarch64|i686|armv7)$/;
|
||||
const checksumManifestPath =
|
||||
optionValue("--checksum-manifest") ||
|
||||
process.env.DESKTOP_UPDATE_CHECKSUM_MANIFEST ||
|
||||
@@ -86,6 +102,56 @@ const assertManifestEntries = async (checksums) => {
|
||||
}
|
||||
};
|
||||
|
||||
const verifyProvenance = async (checksums) => {
|
||||
if (!requiresProvenance) return;
|
||||
if (!provenancePath) {
|
||||
fail("Desktop release provenance path is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
let provenance;
|
||||
try {
|
||||
provenance = JSON.parse(await readFile(provenancePath, "utf8"));
|
||||
} catch (error) {
|
||||
fail(`Cannot read desktop release provenance ${provenancePath}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(provenance.schemaVersion === 1, "Desktop release provenance schemaVersion must be 1.");
|
||||
assert(provenance.version === packageInfo.version, "Desktop release provenance version must match the package version.");
|
||||
assert(provenance.tag === `v${packageInfo.version}`, "Desktop release provenance tag must match the package version.");
|
||||
assert(/^[0-9a-f]{40}$/i.test(provenance.commit || ""), "Desktop release provenance must contain a full commit SHA.");
|
||||
assert(
|
||||
provenance.platformSigningMode === desktopReleaseResolution.platformSigningMode,
|
||||
`Desktop release provenance platformSigningMode must be ${desktopReleaseResolution.platformSigningMode}.`,
|
||||
);
|
||||
assert(
|
||||
provenance.platformSigning?.macos === desktopReleaseResolution.macosSigning,
|
||||
`Desktop release provenance macOS signing must be ${desktopReleaseResolution.macosSigning}.`,
|
||||
);
|
||||
assert(
|
||||
provenance.platformSigning?.windows === desktopReleaseResolution.windowsSigning,
|
||||
`Desktop release provenance Windows signing must be ${desktopReleaseResolution.windowsSigning}.`,
|
||||
);
|
||||
assert(
|
||||
provenance.updaterSigning === "required-and-verified",
|
||||
"Desktop release provenance must record required-and-verified updater signing.",
|
||||
);
|
||||
assert(
|
||||
provenance.artifactLabel === desktopReleaseResolution.artifactLabel,
|
||||
`Desktop release provenance artifactLabel must be ${desktopReleaseResolution.artifactLabel}.`,
|
||||
);
|
||||
if (desktopReleaseResolution.warningRequired) {
|
||||
assert(
|
||||
Array.isArray(provenance.warnings) && provenance.warnings.length >= 2,
|
||||
"Unsigned platform release provenance must include installation trust warnings.",
|
||||
);
|
||||
}
|
||||
if (artifactDir) {
|
||||
await assertChecksum(checksums, provenancePath, "desktop release provenance");
|
||||
}
|
||||
};
|
||||
|
||||
let feed;
|
||||
try {
|
||||
feed = JSON.parse(await readFile(feedPath, "utf8"));
|
||||
@@ -96,6 +162,7 @@ try {
|
||||
if (feed) {
|
||||
const checksums = await readChecksumManifest();
|
||||
await assertManifestEntries(checksums);
|
||||
await verifyProvenance(checksums);
|
||||
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
|
||||
const platforms = feed.platforms || {};
|
||||
const darwinArm = platforms["darwin-aarch64"];
|
||||
@@ -103,15 +170,16 @@ if (feed) {
|
||||
|
||||
assert(normalizedFeedVersion === packageInfo.version, `latest.json version must match package version ${packageInfo.version}.`);
|
||||
assert(Boolean(feed.pub_date || feed.pubDate), "latest.json must include a publication date.");
|
||||
assert(Boolean(darwinArm), "latest.json must include darwin-aarch64.");
|
||||
assert(Boolean(darwinIntel), "latest.json must include darwin-x86_64.");
|
||||
for (const platform of requiredPlatforms) {
|
||||
assert(platformPattern.test(platform), `Required updater platform key is unsupported: ${platform}.`);
|
||||
assert(Boolean(platforms[platform]), `latest.json must include ${platform}.`);
|
||||
}
|
||||
|
||||
const entries = [
|
||||
["darwin-aarch64", darwinArm],
|
||||
["darwin-x86_64", darwinIntel],
|
||||
];
|
||||
const entries = Object.entries(platforms);
|
||||
assert(entries.length > 0, "latest.json must include at least one platform entry.");
|
||||
|
||||
for (const [platform, entry] of entries) {
|
||||
assert(platformPattern.test(platform), `latest.json contains an unsupported platform key: ${platform}.`);
|
||||
const rawUrl = entry?.url;
|
||||
const signature = entry?.signature;
|
||||
assert(typeof signature === "string" && signature.length > 80, `${platform} must include an updater signature.`);
|
||||
@@ -133,6 +201,12 @@ if (feed) {
|
||||
if (expectedBaseUrl) {
|
||||
assert(rawUrl.startsWith(expectedBaseUrl), `${platform} artifact URL must start with ${expectedBaseUrl}.`);
|
||||
}
|
||||
if (platform.startsWith("darwin-")) {
|
||||
assert(url.pathname.endsWith(".app.tar.gz"), `${platform} must point to a macOS .app.tar.gz updater artifact.`);
|
||||
}
|
||||
if (platform === "windows-x86_64") {
|
||||
assert(url.pathname.endsWith(".nsis.zip"), "windows-x86_64 must point to an NSIS .nsis.zip updater artifact.");
|
||||
}
|
||||
|
||||
if (artifactDir) {
|
||||
const artifactPath = resolve(artifactDir, basename(url.pathname));
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const rootDir = resolve(frontendDir, "..");
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const desktopReleasePolicy = await loadDesktopReleasePolicy();
|
||||
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
|
||||
const failures = [];
|
||||
|
||||
const allowedChannels = new Set(["dev", "main", "release", "local"]);
|
||||
@@ -16,6 +21,38 @@ const commit = env.VITE_BUILD_COMMIT || "local";
|
||||
const isCi = env.CI === "true" || env.GITHUB_ACTIONS === "true";
|
||||
const isTagBuild = env.GITHUB_REF_TYPE === "tag";
|
||||
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
|
||||
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
|
||||
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
|
||||
const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true";
|
||||
const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true";
|
||||
const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE;
|
||||
const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM;
|
||||
const isNativeDesktopRelease = Boolean(
|
||||
desktopReleasePlatform ||
|
||||
desktopPlatformSigningMode ||
|
||||
requiresMacosSigning ||
|
||||
requiresWindowsSigning ||
|
||||
requiresUpdaterSigning,
|
||||
);
|
||||
|
||||
const validateDesktopServerUrl = () => {
|
||||
const raw = env.VITE_DESKTOP_SERVER_URL;
|
||||
requireEnv("VITE_DESKTOP_SERVER_URL");
|
||||
if (!raw) return;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch (error) {
|
||||
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
|
||||
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
|
||||
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
|
||||
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
|
||||
};
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
@@ -23,7 +60,19 @@ const assert = (condition, message) => {
|
||||
};
|
||||
|
||||
const requireEnv = (name) => {
|
||||
assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
|
||||
assert(Boolean(env[name]), `${name} must be configured for this desktop release build.`);
|
||||
};
|
||||
|
||||
const gitHead = () => {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...allowedChannels].join(", ")}.`);
|
||||
@@ -31,6 +80,7 @@ assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...al
|
||||
if (isCi || isReleaseBuild) {
|
||||
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
|
||||
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
|
||||
validateDesktopServerUrl();
|
||||
}
|
||||
|
||||
if (env.GITHUB_SHA) {
|
||||
@@ -40,18 +90,77 @@ if (env.GITHUB_SHA) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isReleaseBuild && fullShaPattern.test(commit)) {
|
||||
const headSha = gitHead();
|
||||
assert(Boolean(headSha), "Release builds must be able to resolve the current Git commit.");
|
||||
if (headSha) {
|
||||
assert(commit === headSha, "VITE_BUILD_COMMIT must match the current Git HEAD for release builds.");
|
||||
}
|
||||
}
|
||||
|
||||
if (isTagBuild) {
|
||||
assert(env.GITHUB_REF_NAME === semverTag, `Release tag must be ${semverTag}; found ${env.GITHUB_REF_NAME || "<missing>"}.`);
|
||||
assert(channel === "release", "Release tag builds must set VITE_BUILD_CHANNEL=release.");
|
||||
}
|
||||
|
||||
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
|
||||
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
|
||||
assert(
|
||||
!(requiresMacosSigning && requiresWindowsSigning),
|
||||
"macOS and Windows signing requirements must be checked in their native jobs.",
|
||||
);
|
||||
|
||||
if (isNativeDesktopRelease) {
|
||||
assert(isReleaseBuild, "Native desktop release settings may only be used for release builds.");
|
||||
assert(
|
||||
desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning,
|
||||
"Formal desktop releases must set REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.",
|
||||
);
|
||||
assert(
|
||||
desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode,
|
||||
`DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`,
|
||||
);
|
||||
assert(
|
||||
desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows",
|
||||
"DESKTOP_RELEASE_PLATFORM must be macos or windows for native desktop release builds.",
|
||||
);
|
||||
if (isCi) {
|
||||
assert(isTagBuild, "Signed desktop release candidate builds in CI must run from a release tag.");
|
||||
assert(isTagBuild, "Native desktop release candidate builds in CI must run from a release tag.");
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresUpdaterSigning) {
|
||||
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
|
||||
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
|
||||
}
|
||||
|
||||
if (desktopPlatformSigningMode === "signed") {
|
||||
assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE.");
|
||||
assert(
|
||||
(desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) ||
|
||||
(desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning),
|
||||
"Signed native release jobs must enable only their matching platform signing requirement.",
|
||||
);
|
||||
}
|
||||
|
||||
if (desktopPlatformSigningMode === "unsigned-exception") {
|
||||
assert(
|
||||
desktopReleaseResolution.platformSigningMode === "unsigned-exception",
|
||||
`v${packageInfo.version} has no approved unsigned platform release exception.`,
|
||||
);
|
||||
assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true.");
|
||||
assert(
|
||||
!requiresMacosSigning && !requiresWindowsSigning,
|
||||
"Unsigned platform exceptions must not claim Apple or Windows platform signing.",
|
||||
);
|
||||
if (desktopReleasePlatform === "macos") {
|
||||
assert(process.platform === "darwin", "The macOS ad-hoc release exception must run on macOS.");
|
||||
}
|
||||
if (desktopReleasePlatform === "windows") {
|
||||
assert(process.platform === "win32", "The unsigned Windows release exception must run on Windows.");
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresMacosSigning) {
|
||||
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
|
||||
requireEnv("APPLE_ID");
|
||||
requireEnv("APPLE_PASSWORD");
|
||||
requireEnv("APPLE_TEAM_ID");
|
||||
@@ -64,6 +173,13 @@ if (env.REQUIRE_DESKTOP_SIGNING === "true") {
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresWindowsSigning) {
|
||||
assert(process.platform === "win32", "Signed Windows desktop release builds must run on Windows.");
|
||||
requireEnv("WINDOWS_CERTIFICATE");
|
||||
requireEnv("WINDOWS_CERTIFICATE_PASSWORD");
|
||||
requireEnv("WINDOWS_TIMESTAMP_URL");
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Release build environment check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwQjI5MUZFMjQ2NUM5QwpSV1NjWEViaUh5a0xBdE52U2Rhb29mZlVYZ3lnWGlDVGs1WE1RUGoyeWtSWW9pNzBnNW9qUGNaaAo="
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5MzI4NzBDRDlCRDUzMUIKUldRYlU3M1pESWN5V1ZaYzZOdVlhbk5xVmFlQW4zRmpGbmFUdWV1UUN3WFBsYU16clZJMzRaVjEK"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiGet, apiPost, apiPut } from "./axios";
|
||||
import type { NotificationItem } from "../types/notifications";
|
||||
import type { GeneralNotificationItem } from "../types/notifications";
|
||||
|
||||
export interface DesktopNotificationSubscription {
|
||||
enabled: boolean;
|
||||
@@ -9,7 +9,7 @@ export interface DesktopNotificationSubscription {
|
||||
export interface DesktopNotificationClaim {
|
||||
claim_token?: string | null;
|
||||
lease_expires_at?: string | null;
|
||||
items: NotificationItem[];
|
||||
items: GeneralNotificationItem[];
|
||||
}
|
||||
|
||||
export const getDesktopNotificationSubscription = () =>
|
||||
|
||||
@@ -11,18 +11,23 @@ describe("general notification API", () => {
|
||||
it("loads an uncached recipient feed and updates read state", async () => {
|
||||
const {
|
||||
listGeneralNotifications,
|
||||
markAllGeneralNotificationsRead,
|
||||
markGeneralNotificationRead,
|
||||
} = await import("./notifications");
|
||||
|
||||
listGeneralNotifications("study-1", 8);
|
||||
listGeneralNotifications("study-1", { limit: 8, unread_only: true });
|
||||
markGeneralNotificationRead("study-1", "notification-1");
|
||||
markAllGeneralNotificationsRead("study-1");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(
|
||||
"/api/v1/studies/study-1/notifications/feed",
|
||||
{ params: { limit: 8 }, cache: false, disableRequestDedupe: true },
|
||||
{ params: { limit: 8, unread_only: true }, cache: false, disableRequestDedupe: true },
|
||||
);
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
"/api/v1/studies/study-1/notifications/notification-1/read",
|
||||
);
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
"/api/v1/studies/study-1/notifications/read-all",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { apiGet, apiPost } from "./axios";
|
||||
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
|
||||
|
||||
export const listGeneralNotifications = (studyId: string, limit = 10) =>
|
||||
apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
|
||||
params: { limit },
|
||||
cache: false,
|
||||
disableRequestDedupe: true,
|
||||
});
|
||||
export interface GeneralNotificationQuery {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
unread_only?: boolean;
|
||||
requires_action?: boolean;
|
||||
}
|
||||
|
||||
export const listGeneralNotifications = (
|
||||
studyId: string,
|
||||
query: GeneralNotificationQuery = {},
|
||||
) => apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
|
||||
params: { limit: 10, ...query },
|
||||
cache: false,
|
||||
disableRequestDedupe: true,
|
||||
});
|
||||
|
||||
export const markGeneralNotificationRead = (studyId: string, notificationId: string) =>
|
||||
apiPost<GeneralNotificationItem>(
|
||||
`/api/v1/studies/${studyId}/notifications/${notificationId}/read`,
|
||||
);
|
||||
|
||||
export const markAllGeneralNotificationsRead = (studyId: string) =>
|
||||
apiPost<void>(`/api/v1/studies/${studyId}/notifications/read-all`);
|
||||
|
||||
@@ -260,6 +260,9 @@ describe("ApiEndpointPermissions.vue", () => {
|
||||
]);
|
||||
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:read")).toHaveLength(2);
|
||||
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2);
|
||||
expect((wrapper.vm as any).displayOperations).toHaveLength(5);
|
||||
expect((wrapper.vm as any).totalPermissionCount).toBe(3);
|
||||
expect((wrapper.vm as any).filteredPermissionCount).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps contract fee permissions as one module section", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="toolbar-title">有效权限矩阵</span>
|
||||
<el-tag size="small" effect="plain" type="info">只读</el-tag>
|
||||
</div>
|
||||
<span class="toolbar-result">显示 {{ filteredOperations.length }} / {{ displayOperations.length }} 项权限</span>
|
||||
<span class="toolbar-result">显示 {{ filteredPermissionCount }} / {{ totalPermissionCount }} 项权限</span>
|
||||
</div>
|
||||
<div class="toolbar-filters">
|
||||
<el-input
|
||||
@@ -266,6 +266,12 @@ const filteredOperations = computed(() => {
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const countUniquePermissions = (rows: DisplayOperation[]): number =>
|
||||
new Set(rows.map((operation) => operation.operation_key)).size;
|
||||
|
||||
const totalPermissionCount = computed(() => countUniquePermissions(displayOperations.value));
|
||||
const filteredPermissionCount = computed(() => countUniquePermissions(filteredOperations.value));
|
||||
|
||||
// 权限类型、模块与模块细分列合并行
|
||||
const spanMethod = ({ row, rowIndex, columnIndex }: { row: DisplayOperation; rowIndex: number; column: any; columnIndex: number }) => {
|
||||
if (columnIndex === 0) {
|
||||
|
||||
@@ -296,6 +296,18 @@
|
||||
<div v-else class="reminder-empty">
|
||||
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
|
||||
</div>
|
||||
<div class="reminder-actions">
|
||||
<el-dropdown-item command="notification:center" class="reminder-footer">
|
||||
查看全部提醒
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="headerReminderTotal > 0"
|
||||
command="notification:read-all"
|
||||
class="reminder-footer is-secondary"
|
||||
>
|
||||
全部已读
|
||||
</el-dropdown-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
@@ -2148,6 +2160,28 @@ useDesktopShortcuts(
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reminder-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid rgba(138, 155, 176, 0.2);
|
||||
}
|
||||
|
||||
.reminder-actions .reminder-footer:only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.reminder-footer {
|
||||
justify-content: center;
|
||||
color: #2f6fed !important;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reminder-footer.is-secondary {
|
||||
color: #718096 !important;
|
||||
}
|
||||
|
||||
.activity-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const readTauriLibSource = () => readSource(resolve(__dirname, "../../src-tauri/
|
||||
const readDesktopPreferencesSource = () => readSource(resolve(__dirname, "../views/DesktopPreferences.vue"));
|
||||
const readDesktopServerSettingsSource = () => readSource(resolve(__dirname, "../views/DesktopServerSettings.vue"));
|
||||
const readDesktopUpdateManagerSource = () => readSource(resolve(__dirname, "../session/desktopUpdateManager.ts"));
|
||||
const readDesktopNotificationManagerSource = () => readSource(resolve(__dirname, "../session/desktopNotificationManager.ts"));
|
||||
const readDesktopActivityCenterSource = () => readSource(resolve(__dirname, "../session/desktopActivityCenter.ts"));
|
||||
const readProfileSettingsSource = () => readSource(resolve(__dirname, "../views/ProfileSettings.vue"));
|
||||
const readLoginSource = () => readSource(resolve(__dirname, "../views/Login.vue"));
|
||||
@@ -484,9 +485,10 @@ describe("desktop layout shell", () => {
|
||||
expect(navigation).toContain("item.children?.length ? item.children : [item]");
|
||||
});
|
||||
|
||||
it("keeps notification and update preferences lightweight", () => {
|
||||
it("shows the real notification delivery path and keeps native notification access constrained", () => {
|
||||
const serverSettings = readDesktopServerSettingsSource();
|
||||
const preferences = readDesktopPreferencesSource();
|
||||
const desktopNotificationManager = readDesktopNotificationManagerSource();
|
||||
const desktopUpdateManager = readDesktopUpdateManagerSource();
|
||||
const desktopLayout = readDesktopLayoutSource();
|
||||
|
||||
@@ -503,17 +505,25 @@ describe("desktop layout shell", () => {
|
||||
expect(preferences).not.toContain("服务器连通性正常");
|
||||
expect(preferences).not.toContain("服务器连接已确认");
|
||||
expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })");
|
||||
expect(preferences).not.toContain("系统通知已开启");
|
||||
expect(preferences).not.toContain("系统通知已关闭");
|
||||
expect(preferences).not.toContain("已授权");
|
||||
expect(preferences).toContain("showNotificationPermissionTag");
|
||||
expect(preferences).toContain("系统权限:{{ notificationPermissionText }}");
|
||||
expect(preferences).toContain("服务端订阅");
|
||||
expect(preferences).toContain("真实业务路径");
|
||||
expect(preferences).toContain("授权并开启");
|
||||
expect(preferences).toContain("重新检测权限");
|
||||
expect(preferences).toContain("立即检查业务提醒");
|
||||
expect(preferences).toContain("系统设置 → 通知 → CTMS");
|
||||
expect(preferences).toContain("设置 → 系统 → 通知 → CTMS");
|
||||
expect(preferences).toContain("showSystemNotificationProbe");
|
||||
expect(preferences).toContain("sendDesktopNotificationTest");
|
||||
expect(preferences).toContain("测试通知");
|
||||
expect(preferences).not.toContain("通知诊断");
|
||||
expect(preferences).toContain("发送测试通知");
|
||||
expect(preferences).toContain("openProjectNotificationCenter");
|
||||
expect(preferences).toContain("listenDesktopNotificationStatus");
|
||||
expect(preferences).not.toContain("更新诊断");
|
||||
expect(preferences).not.toContain("listenDesktopNotificationDiagnostics");
|
||||
expect(preferences).not.toContain(["@tauri-apps", "plugin-notification"].join("/"));
|
||||
expect(desktopNotificationManager).toContain("claimDesktopNotifications");
|
||||
expect(desktopNotificationManager).toContain("acknowledgeDesktopNotifications");
|
||||
expect(desktopNotificationManager).toContain('state: "permission-required"');
|
||||
expect(desktopNotificationManager).toContain('state: deliveredIds.length ? "delivered" : "ready"');
|
||||
expect(preferences).toContain("listenDesktopUpdateStatus");
|
||||
expect(preferences).toContain("promptForPendingDesktopUpdate");
|
||||
expect(preferences).toContain("当前已是最新版本");
|
||||
@@ -603,6 +613,10 @@ describe("desktop layout shell", () => {
|
||||
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell");
|
||||
expect(styles).toContain("padding: 7px 10px;");
|
||||
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-pagination");
|
||||
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-radio-button__inner");
|
||||
expect(styles).toContain("display: inline-flex;");
|
||||
expect(styles).toContain("align-items: center;");
|
||||
expect(styles).toContain("justify-content: center;");
|
||||
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body");
|
||||
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
|
||||
});
|
||||
|
||||
@@ -289,7 +289,18 @@
|
||||
<div v-else class="header-reminder-empty">
|
||||
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
|
||||
</div>
|
||||
|
||||
<div class="header-reminder-actions">
|
||||
<el-dropdown-item command="notification:center" class="header-reminder-footer">
|
||||
查看全部提醒
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="headerReminderTotal > 0"
|
||||
command="notification:read-all"
|
||||
class="header-reminder-footer is-secondary"
|
||||
>
|
||||
全部已读
|
||||
</el-dropdown-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
@@ -1848,6 +1859,21 @@ useDesktopShortcuts(
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-reminder-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.header-reminder-actions .header-reminder-footer:only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.header-reminder-footer.is-secondary {
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
border: 1px solid transparent;
|
||||
color: var(--ctms-text-secondary);
|
||||
|
||||
@@ -10,11 +10,15 @@ describe("project notification feed contract", () => {
|
||||
it("uses recipient notifications for both web and desktop reminder bells", () => {
|
||||
expect(source).toContain("listGeneralNotifications");
|
||||
expect(source).toContain("markGeneralNotificationRead");
|
||||
expect(source).toContain("markAllGeneralNotificationsRead");
|
||||
expect(source).toContain('/project/notifications');
|
||||
expect(source).toContain("POLL_INTERVAL_MS = 60_000");
|
||||
expect(source).toContain("PROJECT_NOTIFICATIONS_CHANGED_EVENT");
|
||||
expect(webLayout).toContain("useProjectNotifications()");
|
||||
expect(desktopLayout).toContain("useProjectNotifications()");
|
||||
expect(webLayout).not.toContain("fetchOverdueAesCount");
|
||||
expect(desktopLayout).not.toContain("fetchOverdueAesCount");
|
||||
expect(webLayout).toContain('command="notification:center"');
|
||||
expect(desktopLayout).toContain('command="notification:center"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { listGeneralNotifications, markGeneralNotificationRead } from "../api/notifications";
|
||||
import {
|
||||
listGeneralNotifications,
|
||||
markAllGeneralNotificationsRead,
|
||||
markGeneralNotificationRead,
|
||||
} from "../api/notifications";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
|
||||
|
||||
@@ -32,7 +36,7 @@ export const useProjectNotifications = () => {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const headerRemindersLoading = ref(false);
|
||||
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, items: [] });
|
||||
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, total_count: 0, items: [] });
|
||||
let requestId = 0;
|
||||
let pollTimer: number | undefined;
|
||||
|
||||
@@ -40,12 +44,12 @@ export const useProjectNotifications = () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
const currentRequest = ++requestId;
|
||||
if (!studyId || route.path.startsWith("/admin")) {
|
||||
feed.value = { unread_count: 0, items: [] };
|
||||
feed.value = { unread_count: 0, total_count: 0, items: [] };
|
||||
return;
|
||||
}
|
||||
headerRemindersLoading.value = true;
|
||||
try {
|
||||
const { data } = await listGeneralNotifications(studyId, 10);
|
||||
const { data } = await listGeneralNotifications(studyId, { limit: 10 });
|
||||
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
|
||||
} catch {
|
||||
// 短时网络异常时保留当前内存中的提醒,避免角标在轮询失败时闪烁消失。
|
||||
@@ -67,6 +71,17 @@ export const useProjectNotifications = () => {
|
||||
const headerReminderBadgeValue = computed(() => headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value);
|
||||
|
||||
const handleReminderCommand = async (command: string) => {
|
||||
if (command === "notification:center") {
|
||||
await router.push("/project/notifications");
|
||||
return;
|
||||
}
|
||||
if (command === "notification:read-all") {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || feed.value.unread_count <= 0) return;
|
||||
await markAllGeneralNotificationsRead(studyId);
|
||||
await loadHeaderReminders();
|
||||
return;
|
||||
}
|
||||
if (!command.startsWith("notification:")) return;
|
||||
const notificationId = command.slice("notification:".length);
|
||||
const item = feed.value.items.find((candidate) => candidate.id === notificationId);
|
||||
@@ -75,10 +90,15 @@ export const useProjectNotifications = () => {
|
||||
if (!item.read_at) {
|
||||
item.read_at = new Date().toISOString();
|
||||
feed.value.unread_count = Math.max(0, feed.value.unread_count - 1);
|
||||
await markGeneralNotificationRead(studyId, item.id).catch(() => {
|
||||
const response = await markGeneralNotificationRead(studyId, item.id).catch(() => {
|
||||
item.read_at = null;
|
||||
feed.value.unread_count += 1;
|
||||
return null;
|
||||
});
|
||||
if (response?.data.resolved_at) {
|
||||
feed.value.items = feed.value.items.filter((candidate) => candidate.id !== item.id);
|
||||
feed.value.total_count = Math.max(0, feed.value.total_count - 1);
|
||||
}
|
||||
}
|
||||
if (item.action_path?.startsWith("/")) await router.push(item.action_path);
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -3,6 +3,7 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
|
||||
readonly VITE_BUILD_COMMIT?: string;
|
||||
readonly VITE_DESKTOP_SERVER_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -99,7 +99,7 @@ export const TEXT = {
|
||||
dataUpdatedAt: "数据",
|
||||
projectReminders: "提醒",
|
||||
projectRemindersSubtitle: "业务通知与待办",
|
||||
projectRemindersEmpty: "暂无未处理通知",
|
||||
projectRemindersEmpty: "暂无业务提醒",
|
||||
overdueAes: "逾期 AE",
|
||||
overdueAesDesc: "需关注安全性事件处理时效",
|
||||
overdueMonitoringIssues: "逾期监查问题",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createApp, defineComponent } from "vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installElementPlus } from "./elementPlus";
|
||||
|
||||
describe("installElementPlus", () => {
|
||||
it("registers radio group subcomponents used by the monitoring time selector", () => {
|
||||
const app = createApp(defineComponent({ template: "<div />" }));
|
||||
|
||||
installElementPlus(app);
|
||||
|
||||
expect(app.component("ElRadioGroup")).toBeDefined();
|
||||
expect(app.component("ElRadioButton")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,8 @@ const components = [
|
||||
];
|
||||
|
||||
export const installElementPlus = (app: App): void => {
|
||||
for (const component of components) app.use(component);
|
||||
for (const component of components) {
|
||||
if (component.name) app.component(component.name, component);
|
||||
}
|
||||
app.use(ElLoading);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ const EmailSettings = () => import("../views/admin/EmailSettings.vue");
|
||||
const ProjectDetail = () => import("../views/admin/ProjectDetail.vue");
|
||||
const ProjectOverview = () => import("../views/ia/ProjectOverview.vue");
|
||||
const ProjectMilestones = () => import("../views/ia/ProjectMilestones.vue");
|
||||
const ProjectNotifications = () => import("../views/ProjectNotifications.vue");
|
||||
const FeeContracts = () => import("../views/fees/ContractFees.vue");
|
||||
const FeeContractDetail = () => import("../views/fees/ContractFeeDetail.vue");
|
||||
const DrugShipments = () => import("../views/ia/DrugShipments.vue");
|
||||
@@ -149,6 +150,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: ProjectMilestones,
|
||||
meta: { title: TEXT.menu.projectMilestones, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "project/notifications",
|
||||
name: "ProjectNotifications",
|
||||
component: ProjectNotifications,
|
||||
meta: { title: "提醒中心", requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "fees/contracts",
|
||||
name: "FeeContracts",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||
DESKTOP_SERVER_URL_KEY,
|
||||
getDefaultDesktopServerUrl,
|
||||
getDesktopServerUrl,
|
||||
normalizeDesktopServerUrl,
|
||||
setDesktopServerUrl,
|
||||
@@ -31,6 +32,7 @@ const createMemoryStorage = (): Storage => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "");
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: createMemoryStorage(),
|
||||
configurable: true,
|
||||
@@ -40,6 +42,7 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
setTauriRuntime(false);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("desktop server config", () => {
|
||||
@@ -70,6 +73,23 @@ describe("desktop server config", () => {
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the build-time default while allowing a persisted manual override", () => {
|
||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
|
||||
|
||||
expect(getDefaultDesktopServerUrl()).toBe("https://default.ctms.example.com/");
|
||||
expect(getDesktopServerUrl()).toBe("https://default.ctms.example.com/");
|
||||
|
||||
setDesktopServerUrl("https://manual.ctms.example.com");
|
||||
expect(getDesktopServerUrl()).toBe("https://manual.ctms.example.com/");
|
||||
});
|
||||
|
||||
it("ignores an invalid build-time default", () => {
|
||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "http://public.ctms.example.com");
|
||||
|
||||
expect(getDefaultDesktopServerUrl()).toBeNull();
|
||||
expect(getDesktopServerUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a server URL only inside the Tauri runtime", () => {
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||
setTauriRuntime(true);
|
||||
@@ -77,4 +97,11 @@ describe("desktop server config", () => {
|
||||
setDesktopServerUrl("https://ctms.example.com");
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not require first-run configuration when a valid build-time default exists", () => {
|
||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
|
||||
setTauriRuntime(true);
|
||||
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,13 +51,22 @@ export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValida
|
||||
return { ok: true, url: `${parsed.origin}/` };
|
||||
};
|
||||
|
||||
export const getDesktopServerUrl = (): string | null => {
|
||||
export const getDefaultDesktopServerUrl = (): string | null => {
|
||||
const configured = import.meta.env.VITE_DESKTOP_SERVER_URL?.trim();
|
||||
if (!configured) return null;
|
||||
const result = normalizeDesktopServerUrl(configured);
|
||||
return result.ok ? result.url : null;
|
||||
};
|
||||
|
||||
const getStoredDesktopServerUrl = (): string | null => {
|
||||
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
|
||||
if (!stored) return null;
|
||||
const result = normalizeDesktopServerUrl(stored);
|
||||
return result.ok ? result.url : null;
|
||||
};
|
||||
|
||||
export const getDesktopServerUrl = (): string | null => getStoredDesktopServerUrl() || getDefaultDesktopServerUrl();
|
||||
|
||||
export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl());
|
||||
|
||||
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||
@@ -74,7 +83,7 @@ export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationRe
|
||||
export const clearDesktopServerUrl = (): void => {
|
||||
const previous = getDesktopServerUrl();
|
||||
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
|
||||
emitServerUrlChanged(previous, null);
|
||||
emitServerUrlChanged(previous, getDesktopServerUrl());
|
||||
};
|
||||
|
||||
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
|
||||
|
||||
@@ -2,6 +2,7 @@ export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./c
|
||||
export {
|
||||
clearDesktopServerUrl,
|
||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||
getDefaultDesktopServerUrl,
|
||||
getDesktopServerUrl,
|
||||
hasDesktopServerUrl,
|
||||
normalizeDesktopServerUrl,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getNotificationPermission, requestNotificationPermission, showSystemNotificationProbe } from "./notifications";
|
||||
import {
|
||||
getNotificationPermission,
|
||||
requestNotificationPermission,
|
||||
showSystemNotification,
|
||||
showSystemNotificationProbe,
|
||||
} from "./notifications";
|
||||
|
||||
const isPermissionGrantedMock = vi.hoisted(() => vi.fn());
|
||||
const requestPermissionMock = vi.hoisted(() => vi.fn());
|
||||
@@ -96,4 +101,15 @@ describe("notification runtime", () => {
|
||||
body: "系统通知已可用",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps business details out of the desktop system notification", async () => {
|
||||
enableTauriRuntime();
|
||||
setBrowserNotificationPermission("granted");
|
||||
|
||||
expect(await showSystemNotification()).toBe(true);
|
||||
expect(notificationDispatchMock).toHaveBeenCalledWith({
|
||||
title: "CTMS 待办提醒",
|
||||
body: "有新的业务提醒待查看",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,9 @@ type StaticNotificationPayload = {
|
||||
body: string;
|
||||
};
|
||||
|
||||
const fileUpdateNotification: StaticNotificationPayload = {
|
||||
title: "CTMS 文件更新",
|
||||
body: "有新的文件版本待查看",
|
||||
const businessReminderNotification: StaticNotificationPayload = {
|
||||
title: "CTMS 待办提醒",
|
||||
body: "有新的业务提醒待查看",
|
||||
};
|
||||
|
||||
const notificationProbe: StaticNotificationPayload = {
|
||||
@@ -53,7 +53,7 @@ const sendStaticSystemNotification = async (payload: StaticNotificationPayload):
|
||||
};
|
||||
|
||||
export const showSystemNotification = async (): Promise<boolean> =>
|
||||
sendStaticSystemNotification(fileUpdateNotification);
|
||||
sendStaticSystemNotification(businessReminderNotification);
|
||||
|
||||
export const showSystemNotificationProbe = async (): Promise<boolean> =>
|
||||
sendStaticSystemNotification(notificationProbe);
|
||||
|
||||
@@ -55,7 +55,11 @@ describe("desktop notification manager", () => {
|
||||
showNotificationMock
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockRejectedValueOnce(new Error("notification failed"));
|
||||
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
|
||||
const {
|
||||
getDesktopNotificationStatus,
|
||||
initDesktopNotificationManager,
|
||||
triggerDesktopNotificationPoll,
|
||||
} = await import("./desktopNotificationManager");
|
||||
|
||||
initDesktopNotificationManager();
|
||||
triggerDesktopNotificationPoll();
|
||||
@@ -64,6 +68,11 @@ describe("desktop notification manager", () => {
|
||||
expect(showNotificationMock).toHaveBeenCalledTimes(2);
|
||||
expect(acknowledgeNotificationsMock).toHaveBeenCalledWith("claim-token", ["notification-1"]);
|
||||
expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1);
|
||||
expect(getDesktopNotificationStatus()).toMatchObject({
|
||||
state: "error",
|
||||
lastDeliveredCount: 0,
|
||||
consecutiveFailures: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not acknowledge notifications when the system dispatch does not happen", async () => {
|
||||
@@ -83,7 +92,11 @@ describe("desktop notification manager", () => {
|
||||
|
||||
it("does not claim notifications before operating system permission is granted", async () => {
|
||||
getPermissionMock.mockResolvedValue("prompt");
|
||||
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
|
||||
const {
|
||||
getDesktopNotificationStatus,
|
||||
initDesktopNotificationManager,
|
||||
triggerDesktopNotificationPoll,
|
||||
} = await import("./desktopNotificationManager");
|
||||
|
||||
initDesktopNotificationManager();
|
||||
triggerDesktopNotificationPoll();
|
||||
@@ -91,5 +104,54 @@ describe("desktop notification manager", () => {
|
||||
|
||||
expect(claimNotificationsMock).not.toHaveBeenCalled();
|
||||
expect(acknowledgeNotificationsMock).not.toHaveBeenCalled();
|
||||
expect(getDesktopNotificationStatus()).toMatchObject({
|
||||
state: "permission-required",
|
||||
lastDeliveredCount: 0,
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes real delivery progress without exposing claimed reminder content", async () => {
|
||||
const {
|
||||
getDesktopNotificationStatus,
|
||||
initDesktopNotificationManager,
|
||||
listenDesktopNotificationStatus,
|
||||
triggerDesktopNotificationPoll,
|
||||
} = await import("./desktopNotificationManager");
|
||||
const states: string[] = [];
|
||||
const unlisten = listenDesktopNotificationStatus((snapshot) => states.push(snapshot.state));
|
||||
|
||||
initDesktopNotificationManager();
|
||||
triggerDesktopNotificationPoll();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(states).toEqual(expect.arrayContaining(["idle", "polling", "delivered"]));
|
||||
expect(getDesktopNotificationStatus()).toMatchObject({
|
||||
state: "delivered",
|
||||
lastDeliveredCount: 2,
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
expect(getDesktopNotificationStatus().lastCheckedAt).toBeTruthy();
|
||||
expect(getDesktopNotificationStatus().lastDeliveredAt).toBeTruthy();
|
||||
unlisten();
|
||||
});
|
||||
|
||||
it("reports a disabled server subscription without claiming reminders", async () => {
|
||||
getSubscriptionMock.mockResolvedValue({ data: { enabled: false } });
|
||||
const {
|
||||
getDesktopNotificationStatus,
|
||||
initDesktopNotificationManager,
|
||||
triggerDesktopNotificationPoll,
|
||||
} = await import("./desktopNotificationManager");
|
||||
|
||||
initDesktopNotificationManager();
|
||||
triggerDesktopNotificationPoll();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(claimNotificationsMock).not.toHaveBeenCalled();
|
||||
expect(getDesktopNotificationStatus()).toMatchObject({
|
||||
state: "subscription-disabled",
|
||||
lastDeliveredCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,39 @@ import {
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const MAX_BACKOFF_MS = 15 * 60_000;
|
||||
|
||||
export type DesktopNotificationDeliveryState =
|
||||
| "idle"
|
||||
| "signed-out"
|
||||
| "permission-required"
|
||||
| "subscription-disabled"
|
||||
| "polling"
|
||||
| "ready"
|
||||
| "delivered"
|
||||
| "error";
|
||||
|
||||
export interface DesktopNotificationStatusSnapshot {
|
||||
state: DesktopNotificationDeliveryState;
|
||||
lastCheckedAt?: string;
|
||||
lastDeliveredAt?: string;
|
||||
lastDeliveredCount: number;
|
||||
consecutiveFailures: number;
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
let timer: number | null = null;
|
||||
let failureCount = 0;
|
||||
let status: DesktopNotificationStatusSnapshot = {
|
||||
state: "idle",
|
||||
lastDeliveredCount: 0,
|
||||
consecutiveFailures: 0,
|
||||
};
|
||||
const statusListeners = new Set<(snapshot: DesktopNotificationStatusSnapshot) => void>();
|
||||
|
||||
const publishStatus = (patch: Partial<DesktopNotificationStatusSnapshot>) => {
|
||||
status = { ...status, ...patch };
|
||||
const snapshot = { ...status };
|
||||
statusListeners.forEach((listener) => listener(snapshot));
|
||||
};
|
||||
|
||||
const schedule = (delay: number) => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
@@ -23,22 +53,41 @@ const schedule = (delay: number) => {
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
timer = null;
|
||||
if (!getToken()) {
|
||||
failureCount = 0;
|
||||
publishStatus({
|
||||
state: "signed-out",
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const permission = await getNotificationPermission();
|
||||
if (permission !== "granted") {
|
||||
failureCount = 0;
|
||||
publishStatus({
|
||||
state: "permission-required",
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
const { data: subscription } = await getDesktopNotificationSubscription();
|
||||
if (!subscription.enabled) {
|
||||
failureCount = 0;
|
||||
publishStatus({
|
||||
state: "subscription-disabled",
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
publishStatus({ state: "polling" });
|
||||
const { data } = await claimDesktopNotifications();
|
||||
const deliveredIds: string[] = [];
|
||||
let deliveryFailed = false;
|
||||
@@ -60,13 +109,36 @@ const poll = async () => {
|
||||
throw new Error("desktop notification delivery failed");
|
||||
}
|
||||
failureCount = 0;
|
||||
const checkedAt = new Date().toISOString();
|
||||
publishStatus({
|
||||
state: deliveredIds.length ? "delivered" : "ready",
|
||||
lastCheckedAt: checkedAt,
|
||||
lastDeliveredAt: deliveredIds.length ? checkedAt : status.lastDeliveredAt,
|
||||
lastDeliveredCount: deliveredIds.length || status.lastDeliveredCount,
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
} catch {
|
||||
failureCount += 1;
|
||||
publishStatus({
|
||||
state: "error",
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
consecutiveFailures: failureCount,
|
||||
});
|
||||
schedule(Math.min(POLL_INTERVAL_MS * 2 ** failureCount, MAX_BACKOFF_MS));
|
||||
}
|
||||
};
|
||||
|
||||
export const getDesktopNotificationStatus = (): DesktopNotificationStatusSnapshot => ({ ...status });
|
||||
|
||||
export const listenDesktopNotificationStatus = (
|
||||
listener: (snapshot: DesktopNotificationStatusSnapshot) => void,
|
||||
): (() => void) => {
|
||||
statusListeners.add(listener);
|
||||
listener(getDesktopNotificationStatus());
|
||||
return () => statusListeners.delete(listener);
|
||||
};
|
||||
|
||||
export const initDesktopNotificationManager = (): void => {
|
||||
if (initialized || !isTauriRuntime()) return;
|
||||
initialized = true;
|
||||
@@ -84,4 +156,9 @@ export const stopDesktopNotificationManager = (): void => {
|
||||
timer = null;
|
||||
initialized = false;
|
||||
failureCount = 0;
|
||||
publishStatus({
|
||||
state: "idle",
|
||||
lastDeliveredCount: 0,
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -930,9 +930,13 @@ body {
|
||||
}
|
||||
|
||||
.desktop-workbench .desktop-route-shell .el-radio-button__inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 26px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.desktop-workbench .desktop-route-shell .el-drawer__header {
|
||||
|
||||
@@ -25,11 +25,15 @@ export interface GeneralNotificationItem {
|
||||
action_path?: string | null;
|
||||
source_type: string;
|
||||
source_id: string;
|
||||
requires_action: boolean;
|
||||
due_at?: string | null;
|
||||
read_at?: string | null;
|
||||
resolved_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GeneralNotificationFeed {
|
||||
unread_count: number;
|
||||
total_count: number;
|
||||
items: GeneralNotificationItem[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getContentDispositionFilename } from "./contentDisposition";
|
||||
import { getContentDispositionFilename, getHttpHeaderString } from "./contentDisposition";
|
||||
|
||||
describe("getHttpHeaderString", () => {
|
||||
it("keeps string header values and joins string arrays", () => {
|
||||
expect(getHttpHeaderString("application/pdf")).toBe("application/pdf");
|
||||
expect(getHttpHeaderString(["attachment", "filename=protocol.pdf"])).toBe("attachment, filename=protocol.pdf");
|
||||
});
|
||||
|
||||
it("rejects non-string Axios header values", () => {
|
||||
expect(getHttpHeaderString(200)).toBeUndefined();
|
||||
expect(getHttpHeaderString(true)).toBeUndefined();
|
||||
expect(getHttpHeaderString({ value: "application/pdf" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContentDispositionFilename", () => {
|
||||
it("prefers the UTF-8 filename even when the ASCII fallback appears first", () => {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export const getHttpHeaderString = (value: unknown): string | undefined => {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
||||
return value.join(", ");
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getContentDispositionFilename = (header?: string | null): string | null => {
|
||||
if (!header) return null;
|
||||
const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)?.[1];
|
||||
|
||||
@@ -153,24 +153,84 @@
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeSectionId === 'notifications'" key="notifications" class="preference-panel">
|
||||
<div class="preference-row">
|
||||
<div>
|
||||
<div class="row-title">系统通知</div>
|
||||
<div class="row-desc">不含项目详情的文件更新提示</div>
|
||||
<div class="notification-settings">
|
||||
<div class="preference-row notification-primary-row">
|
||||
<div>
|
||||
<div class="row-title">系统通知</div>
|
||||
<div class="row-desc">开启后定期领取服务端真实业务提醒,系统弹窗不显示业务详情</div>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<el-tag size="small" :type="notificationPermissionTagType">
|
||||
系统权限:{{ notificationPermissionText }}
|
||||
</el-tag>
|
||||
<el-switch
|
||||
:model-value="desktopNotificationsEnabled"
|
||||
:loading="desktopNotificationLoading"
|
||||
aria-label="系统通知服务端订阅"
|
||||
@change="onDesktopNotificationChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<el-switch
|
||||
v-model="desktopNotificationsEnabled"
|
||||
:loading="desktopNotificationLoading"
|
||||
@change="onDesktopNotificationChange"
|
||||
|
||||
<div class="notification-guidance" :class="`is-${notificationGuidanceTone}`">
|
||||
<el-alert
|
||||
:type="notificationGuidanceTone"
|
||||
:title="notificationGuidanceTitle"
|
||||
:description="notificationGuidanceDescription"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-tag v-if="showNotificationPermissionTag" size="small" :type="notificationPermissionTagType">
|
||||
{{ notificationPermissionText }}
|
||||
</el-tag>
|
||||
<el-button size="small" :loading="desktopNotificationTestLoading" @click="sendDesktopNotificationTest">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>测试通知</span>
|
||||
</el-button>
|
||||
|
||||
<div class="notification-status-grid" aria-label="系统通知投递状态">
|
||||
<span>系统权限</span>
|
||||
<strong>{{ notificationPermissionText }}</strong>
|
||||
<span>服务端订阅</span>
|
||||
<strong>{{ desktopNotificationsEnabled ? "已开启" : "未开启" }}</strong>
|
||||
<span>真实业务路径</span>
|
||||
<strong>{{ notificationDeliveryStatusText }}</strong>
|
||||
<span>最近检查</span>
|
||||
<strong>{{ notificationLastCheckedText }}</strong>
|
||||
<span>最近投递</span>
|
||||
<strong>{{ notificationLastDeliveredText }}</strong>
|
||||
</div>
|
||||
|
||||
<p class="notification-privacy-note">
|
||||
系统弹窗固定显示“CTMS 待办提醒”,项目、文件、AE、监查问题和受试者信息仅在登录后的提醒中心展示。
|
||||
</p>
|
||||
|
||||
<div class="notification-actions">
|
||||
<el-button
|
||||
v-if="notificationPermission === 'prompt'"
|
||||
type="primary"
|
||||
:loading="desktopNotificationLoading"
|
||||
@click="authorizeAndEnableDesktopNotifications"
|
||||
>
|
||||
授权并开启
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="notificationPermission === 'denied'"
|
||||
:loading="desktopNotificationLoading"
|
||||
@click="refreshDesktopNotificationPermission"
|
||||
>
|
||||
重新检测权限
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="notificationPermission === 'granted' && desktopNotificationsEnabled"
|
||||
:loading="desktopNotificationCheckLoading"
|
||||
@click="checkDesktopBusinessNotifications"
|
||||
>
|
||||
立即检查业务提醒
|
||||
</el-button>
|
||||
<el-button
|
||||
:disabled="notificationPermission === 'unsupported'"
|
||||
:loading="desktopNotificationTestLoading"
|
||||
@click="sendDesktopNotificationTest"
|
||||
>
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>发送测试通知</span>
|
||||
</el-button>
|
||||
<el-button text @click="openProjectNotificationCenter">打开提醒中心</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -253,7 +313,12 @@ import {
|
||||
promptForPendingDesktopUpdate,
|
||||
type DesktopUpdateStatusSnapshot,
|
||||
} from "../session/desktopUpdateManager";
|
||||
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
|
||||
import {
|
||||
getDesktopNotificationStatus,
|
||||
listenDesktopNotificationStatus,
|
||||
triggerDesktopNotificationPoll,
|
||||
type DesktopNotificationStatusSnapshot,
|
||||
} from "../session/desktopNotificationManager";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
|
||||
@@ -309,8 +374,10 @@ const desktopShortcuts = ref<DesktopShortcutPreferences>(readDesktopShortcutPref
|
||||
const capturingShortcutAction = ref<DesktopShortcutAction | null>(null);
|
||||
const shortcutCaptureElement = ref<HTMLElement | null>(null);
|
||||
const notificationPermission = ref<NotificationPermissionState>("unsupported");
|
||||
const desktopNotificationStatus = ref<DesktopNotificationStatusSnapshot>(getDesktopNotificationStatus());
|
||||
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
|
||||
let updateStatusUnlisten: (() => void) | undefined;
|
||||
let notificationStatusUnlisten: (() => void) | undefined;
|
||||
let connectionPendingTimer: number | undefined;
|
||||
const themeOptions = [
|
||||
{ value: "system" as const, label: "跟随系统", icon: Monitor },
|
||||
@@ -345,19 +412,95 @@ const clientMetadataRows = computed(() => [
|
||||
]);
|
||||
|
||||
const notificationPermissionText = computed(() => {
|
||||
if (notificationPermission.value === "granted") return "已授权";
|
||||
if (notificationPermission.value === "denied") return "已拒绝";
|
||||
if (notificationPermission.value === "prompt") return "待授权";
|
||||
return "不可用";
|
||||
});
|
||||
|
||||
const showNotificationPermissionTag = computed(() => notificationPermission.value !== "granted");
|
||||
|
||||
const notificationPermissionTagType = computed(() => {
|
||||
if (notificationPermission.value === "granted") return "success";
|
||||
if (notificationPermission.value === "denied") return "danger";
|
||||
if (notificationPermission.value === "prompt") return "warning";
|
||||
return "info";
|
||||
});
|
||||
|
||||
const desktopNotificationCheckLoading = computed(() => desktopNotificationStatus.value.state === "polling");
|
||||
|
||||
const formatNotificationTime = (value?: string) => {
|
||||
if (!value) return "尚未检查";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "尚未检查";
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
};
|
||||
|
||||
const notificationDeliveryStatusText = computed(() => {
|
||||
const labels: Record<DesktopNotificationStatusSnapshot["state"], string> = {
|
||||
idle: "等待桌面通知服务启动",
|
||||
"signed-out": "登录后自动检查",
|
||||
"permission-required": "等待系统授权",
|
||||
"subscription-disabled": "等待开启服务端订阅",
|
||||
polling: "正在调用真实业务提醒接口",
|
||||
ready: "链路正常,暂无待投递提醒",
|
||||
delivered: "已完成领取、系统投递和服务端确认",
|
||||
error: "检查失败,将自动退避重试",
|
||||
};
|
||||
return labels[desktopNotificationStatus.value.state];
|
||||
});
|
||||
|
||||
const notificationLastCheckedText = computed(() =>
|
||||
formatNotificationTime(desktopNotificationStatus.value.lastCheckedAt),
|
||||
);
|
||||
|
||||
const notificationLastDeliveredText = computed(() => {
|
||||
if (!desktopNotificationStatus.value.lastDeliveredAt) return "本次运行尚无投递";
|
||||
return `${formatNotificationTime(desktopNotificationStatus.value.lastDeliveredAt)} · ${desktopNotificationStatus.value.lastDeliveredCount} 条`;
|
||||
});
|
||||
|
||||
const notificationSystemSettingsPath = computed(() => {
|
||||
if (clientMetadata.platform === "macos") return "系统设置 → 通知 → CTMS → 打开“允许通知”";
|
||||
if (clientMetadata.platform === "windows") return "设置 → 系统 → 通知 → CTMS → 打开通知";
|
||||
return "系统通知设置 → CTMS → 允许通知";
|
||||
});
|
||||
|
||||
const notificationGuidanceTone = computed<"success" | "warning" | "error" | "info">(() => {
|
||||
if (notificationPermission.value === "unsupported") return "info";
|
||||
if (notificationPermission.value === "denied") return "error";
|
||||
if (notificationPermission.value === "prompt") return "warning";
|
||||
if (desktopNotificationStatus.value.state === "error") return "error";
|
||||
return desktopNotificationsEnabled.value ? "success" : "warning";
|
||||
});
|
||||
|
||||
const notificationGuidanceTitle = computed(() => {
|
||||
if (notificationPermission.value === "unsupported") return "当前客户端不支持系统通知";
|
||||
if (notificationPermission.value === "denied") return "需要在系统设置中恢复 CTMS 通知权限";
|
||||
if (notificationPermission.value === "prompt") return "允许 CTMS 发送系统通知";
|
||||
if (!desktopNotificationsEnabled.value) return "系统权限已就绪,服务端订阅尚未开启";
|
||||
if (desktopNotificationStatus.value.state === "error") return "真实业务提醒检查失败";
|
||||
if (desktopNotificationStatus.value.state === "delivered") return "真实业务提醒已完成系统投递";
|
||||
return "真实业务提醒已开启";
|
||||
});
|
||||
|
||||
const notificationGuidanceDescription = computed(() => {
|
||||
if (notificationPermission.value === "unsupported") return "请在 CTMS Desktop 中配置系统通知。";
|
||||
if (notificationPermission.value === "denied") {
|
||||
return `macOS/Windows 不会再次弹出授权框。请前往:${notificationSystemSettingsPath.value},返回后点击“重新检测权限”。`;
|
||||
}
|
||||
if (notificationPermission.value === "prompt") {
|
||||
return "点击“授权并开启”后,系统会请求一次通知权限;允许后才会开启当前账号的服务端订阅。";
|
||||
}
|
||||
if (!desktopNotificationsEnabled.value) return "打开右侧开关后,客户端会定期领取并确认当前账号的待投递业务提醒。";
|
||||
if (desktopNotificationStatus.value.state === "error") return "客户端会自动重试;也可以检查网络后立即重新检查。";
|
||||
return "客户端每分钟检查一次;系统弹窗只作安全提示,业务处理仍在登录后的提醒中心完成。";
|
||||
});
|
||||
|
||||
const desktopUpdateDescription = computed(() => {
|
||||
const pending = desktopUpdateStatus.value.pendingUpdate;
|
||||
if (desktopUpdateStatus.value.checking) return "正在检查签名更新";
|
||||
@@ -621,38 +764,94 @@ const saveDesktopServerConfig = async () => {
|
||||
};
|
||||
|
||||
const loadNotificationState = async () => {
|
||||
notificationPermission.value = await getNotificationPermission();
|
||||
try {
|
||||
notificationPermission.value = await getNotificationPermission();
|
||||
} catch {
|
||||
notificationPermission.value = "unsupported";
|
||||
}
|
||||
try {
|
||||
const { data } = await getDesktopNotificationSubscription();
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
} catch {
|
||||
desktopNotificationsEnabled.value = false;
|
||||
}
|
||||
triggerDesktopNotificationPoll();
|
||||
};
|
||||
|
||||
const updateDesktopNotificationSubscription = async (enabled: boolean) => {
|
||||
if (desktopNotificationLoading.value) return false;
|
||||
const previousEnabled = desktopNotificationsEnabled.value;
|
||||
desktopNotificationLoading.value = true;
|
||||
try {
|
||||
if (enabled) {
|
||||
notificationPermission.value = await getNotificationPermission();
|
||||
if (notificationPermission.value === "prompt") {
|
||||
notificationPermission.value = await requestNotificationPermission();
|
||||
}
|
||||
if (notificationPermission.value !== "granted") {
|
||||
ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const { data } = await setDesktopNotificationSubscription(enabled);
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
triggerDesktopNotificationPoll();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
desktopNotificationsEnabled.value = previousEnabled;
|
||||
ElMessage.error(error?.response?.data?.detail || "系统通知设置失败");
|
||||
return false;
|
||||
} finally {
|
||||
desktopNotificationLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDesktopNotificationChange = async (value: string | number | boolean) => {
|
||||
await updateDesktopNotificationSubscription(Boolean(value));
|
||||
};
|
||||
|
||||
const authorizeAndEnableDesktopNotifications = async () => {
|
||||
await updateDesktopNotificationSubscription(true);
|
||||
};
|
||||
|
||||
const refreshDesktopNotificationPermission = async () => {
|
||||
if (desktopNotificationLoading.value) return;
|
||||
desktopNotificationLoading.value = true;
|
||||
try {
|
||||
if (value) {
|
||||
notificationPermission.value = await requestNotificationPermission();
|
||||
if (notificationPermission.value !== "granted") {
|
||||
desktopNotificationsEnabled.value = false;
|
||||
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
|
||||
return;
|
||||
}
|
||||
notificationPermission.value = await getNotificationPermission();
|
||||
if (notificationPermission.value === "granted") {
|
||||
triggerDesktopNotificationPoll();
|
||||
ElMessage.success("已检测到系统通知权限");
|
||||
return;
|
||||
}
|
||||
const { data } = await setDesktopNotificationSubscription(Boolean(value));
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
if (data.enabled) triggerDesktopNotificationPoll();
|
||||
} catch (error: any) {
|
||||
desktopNotificationsEnabled.value = !Boolean(value);
|
||||
ElMessage.error(error?.response?.data?.detail || "系统通知设置失败");
|
||||
ElMessage.warning(`仍未检测到系统通知权限:${notificationSystemSettingsPath.value}`);
|
||||
} catch {
|
||||
notificationPermission.value = "unsupported";
|
||||
ElMessage.error("系统通知权限检测失败");
|
||||
} finally {
|
||||
desktopNotificationLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkDesktopBusinessNotifications = async () => {
|
||||
if (desktopNotificationCheckLoading.value) return;
|
||||
try {
|
||||
notificationPermission.value = await getNotificationPermission();
|
||||
if (notificationPermission.value !== "granted") {
|
||||
ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`);
|
||||
return;
|
||||
}
|
||||
if (!desktopNotificationsEnabled.value) {
|
||||
await updateDesktopNotificationSubscription(true);
|
||||
return;
|
||||
}
|
||||
triggerDesktopNotificationPoll();
|
||||
ElMessage.info("已发起真实业务提醒检查");
|
||||
} catch {
|
||||
ElMessage.error("业务提醒检查失败");
|
||||
}
|
||||
};
|
||||
|
||||
const sendDesktopNotificationTest = async () => {
|
||||
if (desktopNotificationTestLoading.value) return;
|
||||
desktopNotificationTestLoading.value = true;
|
||||
@@ -662,7 +861,7 @@ const sendDesktopNotificationTest = async () => {
|
||||
notificationPermission.value = await requestNotificationPermission();
|
||||
}
|
||||
if (notificationPermission.value !== "granted") {
|
||||
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
|
||||
ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`);
|
||||
return;
|
||||
}
|
||||
const sent = await showSystemNotificationProbe();
|
||||
@@ -678,6 +877,11 @@ const sendDesktopNotificationTest = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openProjectNotificationCenter = () => {
|
||||
emit("close-request");
|
||||
void router.push("/project/notifications");
|
||||
};
|
||||
|
||||
const checkDesktopUpdateNow = async () => {
|
||||
if (desktopUpdateChecking.value) return;
|
||||
desktopUpdateChecking.value = true;
|
||||
@@ -765,6 +969,9 @@ onMounted(() => {
|
||||
updateStatusUnlisten = listenDesktopUpdateStatus((status) => {
|
||||
desktopUpdateStatus.value = status;
|
||||
});
|
||||
notificationStatusUnlisten = listenDesktopNotificationStatus((status) => {
|
||||
desktopNotificationStatus.value = status;
|
||||
});
|
||||
void loadNotificationState();
|
||||
});
|
||||
|
||||
@@ -773,6 +980,7 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true });
|
||||
clearConnectionPendingTimer();
|
||||
updateStatusUnlisten?.();
|
||||
notificationStatusUnlisten?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1090,6 +1298,7 @@ h3 {
|
||||
.preferences-panel-enter-active .connection-alert,
|
||||
.preferences-panel-enter-active .connection-diagnostic,
|
||||
.preferences-panel-enter-active .preference-row,
|
||||
.preferences-panel-enter-active .notification-guidance,
|
||||
.preferences-panel-enter-active .shortcut-settings,
|
||||
.preferences-panel-enter-active .metadata-panel {
|
||||
animation: preference-card-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
@@ -1287,6 +1496,75 @@ h3 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
* 系统通知授权与真实投递状态
|
||||
* ===================================================== */
|
||||
.notification-settings {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notification-primary-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.notification-guidance {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 14px 16px 16px;
|
||||
border: 1px solid var(--pref-border-card);
|
||||
border-radius: 10px;
|
||||
background: var(--pref-bg-card);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 1px 1px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.notification-guidance :deep(.el-alert) {
|
||||
align-items: flex-start;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.notification-guidance :deep(.el-alert__description) {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.notification-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.notification-status-grid span {
|
||||
color: var(--pref-text-muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.notification-status-grid strong {
|
||||
min-width: 0;
|
||||
color: var(--pref-text-primary);
|
||||
font-size: 11.5px;
|
||||
font-weight: 620;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notification-privacy-note {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f7f9fc;
|
||||
color: var(--pref-text-secondary);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
* 快捷键
|
||||
* ===================================================== */
|
||||
@@ -1528,6 +1806,7 @@ code {
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .server-config-form),
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .connection-diagnostic),
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .preference-row),
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .notification-guidance),
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-settings),
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .metadata-panel) {
|
||||
border-color: var(--pref-border-card);
|
||||
@@ -1547,6 +1826,19 @@ code {
|
||||
color: var(--pref-text-primary);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .notification-status-grid span) {
|
||||
color: var(--pref-text-muted);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .notification-status-grid strong) {
|
||||
color: var(--pref-text-primary);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .notification-privacy-note) {
|
||||
background: #0d1520;
|
||||
color: var(--pref-text-secondary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences .theme-segmented) {
|
||||
@@ -1593,6 +1885,7 @@ code {
|
||||
.preference-panel :deep(.el-button),
|
||||
.connection-alert,
|
||||
.connection-diagnostic,
|
||||
.notification-guidance,
|
||||
.connection-feedback-enter-active,
|
||||
.connection-feedback-leave-active,
|
||||
.preferences-header-enter-active,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const source = readFileSync(resolve(__dirname, "./ProjectNotifications.vue"), "utf8");
|
||||
|
||||
describe("project notification center contract", () => {
|
||||
it("keeps read state separate from actionable business state", () => {
|
||||
expect(source).toContain("已读不代表业务已完成");
|
||||
expect(source).toContain("requires_action");
|
||||
expect(source).not.toContain("include_resolved");
|
||||
expect(source).toContain("markAllGeneralNotificationsRead");
|
||||
expect(source).toContain('category.startsWith("VISIT_WINDOW_")');
|
||||
expect(source).toContain('return "访视窗口"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,739 @@
|
||||
<template>
|
||||
<section class="notification-page" aria-label="提醒中心。已读不代表业务已完成。">
|
||||
<div class="notification-page__canvas">
|
||||
<header class="notification-page__hero">
|
||||
<div class="notification-page__header">
|
||||
<div class="notification-page__intro">
|
||||
<div class="notification-page__title-row">
|
||||
<span class="notification-page__title-icon" aria-hidden="true">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</span>
|
||||
<h1>提醒中心</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-page__summary" aria-label="提醒概览">
|
||||
<div
|
||||
class="notification-summary-item notification-summary-item--unread"
|
||||
>
|
||||
<span class="notification-summary-item__icon" aria-hidden="true">
|
||||
<el-icon><BellFilled /></el-icon>
|
||||
</span>
|
||||
<span class="notification-summary-item__label">未读</span>
|
||||
<strong>{{ feed.unread_count }}</strong>
|
||||
</div>
|
||||
<div class="notification-summary-item">
|
||||
<span class="notification-summary-item__icon" aria-hidden="true">
|
||||
<el-icon><Tickets /></el-icon>
|
||||
</span>
|
||||
<span class="notification-summary-item__label">当前</span>
|
||||
<strong>{{ feed.total_count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-page__actions">
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadNotifications">
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="CircleCheck"
|
||||
:disabled="feed.unread_count === 0"
|
||||
:loading="markingAllRead"
|
||||
@click="markAllRead"
|
||||
>
|
||||
全部标为已读
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="notification-content" aria-label="提醒列表">
|
||||
<div class="notification-toolbar">
|
||||
<div class="notification-toolbar__main">
|
||||
<div class="notification-toolbar__title">
|
||||
<strong>提醒列表</strong>
|
||||
</div>
|
||||
<el-radio-group v-model="filter" aria-label="提醒筛选" @change="resetAndLoad">
|
||||
<el-radio-button value="active">当前提醒</el-radio-button>
|
||||
<el-radio-button value="unread">仅未读</el-radio-button>
|
||||
<el-radio-button value="action">仅待处理</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="notification-list" aria-live="polite">
|
||||
<button
|
||||
v-for="item in feed.items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="notification-row"
|
||||
:class="{ 'is-read': item.read_at, 'is-resolved': item.resolved_at }"
|
||||
@click="openNotification(item)"
|
||||
>
|
||||
<span class="notification-row__mark" :class="`is-${priorityTone(item.priority)}`"></span>
|
||||
<span class="notification-row__main">
|
||||
<span class="notification-row__heading">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<el-tag size="small" effect="plain">{{ categoryLabel(item.category) }}</el-tag>
|
||||
<el-tag
|
||||
v-if="item.requires_action && !item.resolved_at"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="light"
|
||||
>
|
||||
待处理
|
||||
</el-tag>
|
||||
<el-tag v-else-if="item.resolved_at" size="small" type="info" effect="plain">
|
||||
已结束
|
||||
</el-tag>
|
||||
</span>
|
||||
<span class="notification-row__message">{{ item.message }}</span>
|
||||
<span v-if="item.due_at" class="notification-row__due">
|
||||
截止:{{ formatDateTime(item.due_at) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="notification-row__meta">
|
||||
<span>{{ formatDateTime(item.created_at) }}</span>
|
||||
<small :class="{ 'is-unread': !item.read_at }">
|
||||
<span v-if="!item.read_at" aria-hidden="true"></span>
|
||||
{{ item.read_at ? "已读" : "未读" }}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<el-empty
|
||||
v-if="!loading && feed.items.length === 0"
|
||||
class="notification-empty"
|
||||
:image-size="88"
|
||||
>
|
||||
<template #description>
|
||||
<div class="notification-empty__copy">
|
||||
<strong>{{ emptyState }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
<el-button
|
||||
v-if="filter !== 'active'"
|
||||
text
|
||||
type="primary"
|
||||
@click="showAllNotifications"
|
||||
>
|
||||
查看当前提醒
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-if="feed.total_count > pageSize" class="notification-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:page-size="pageSize"
|
||||
:total="feed.total_count"
|
||||
@current-change="loadNotifications"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Bell, BellFilled, CircleCheck, Refresh, Tickets } from "@element-plus/icons-vue";
|
||||
import {
|
||||
listGeneralNotifications,
|
||||
markAllGeneralNotificationsRead,
|
||||
markGeneralNotificationRead,
|
||||
type GeneralNotificationQuery,
|
||||
} from "../api/notifications";
|
||||
import {
|
||||
notifyProjectNotificationsChanged,
|
||||
PROJECT_NOTIFICATIONS_CHANGED_EVENT,
|
||||
} from "../composables/useProjectNotifications";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
|
||||
|
||||
type NotificationFilter = "active" | "unread" | "action";
|
||||
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const markingAllRead = ref(false);
|
||||
const filter = ref<NotificationFilter>("active");
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, total_count: 0, items: [] });
|
||||
let requestId = 0;
|
||||
|
||||
const emptyState = computed(() => {
|
||||
if (filter.value === "unread") return "没有未读提醒";
|
||||
if (filter.value === "action") return "没有待处理事项";
|
||||
return "当前暂无提醒";
|
||||
});
|
||||
|
||||
const queryForFilter = (): GeneralNotificationQuery => {
|
||||
const query: GeneralNotificationQuery = {
|
||||
skip: (page.value - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
};
|
||||
if (filter.value === "unread") query.unread_only = true;
|
||||
if (filter.value === "action") query.requires_action = true;
|
||||
return query;
|
||||
};
|
||||
|
||||
const loadNotifications = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
const currentRequest = ++requestId;
|
||||
if (!studyId) {
|
||||
feed.value = { unread_count: 0, total_count: 0, items: [] };
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listGeneralNotifications(studyId, queryForFilter());
|
||||
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
|
||||
} catch {
|
||||
// 保留当前列表,网络恢复或窗口重新聚焦时会再次同步。
|
||||
} finally {
|
||||
if (currentRequest === requestId) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetAndLoad = () => {
|
||||
page.value = 1;
|
||||
void loadNotifications();
|
||||
};
|
||||
|
||||
const showAllNotifications = () => {
|
||||
filter.value = "active";
|
||||
resetAndLoad();
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || markingAllRead.value) return;
|
||||
markingAllRead.value = true;
|
||||
try {
|
||||
await markAllGeneralNotificationsRead(studyId);
|
||||
notifyProjectNotificationsChanged();
|
||||
await loadNotifications();
|
||||
ElMessage.success("已将当前项目提醒全部标为已读");
|
||||
} finally {
|
||||
markingAllRead.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openNotification = async (item: GeneralNotificationItem) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (!item.read_at && !item.resolved_at) {
|
||||
const response = await markGeneralNotificationRead(studyId, item.id).catch(() => null);
|
||||
if (response) notifyProjectNotificationsChanged();
|
||||
}
|
||||
if (item.action_path?.startsWith("/")) {
|
||||
await router.push(item.action_path);
|
||||
return;
|
||||
}
|
||||
await loadNotifications();
|
||||
};
|
||||
|
||||
const priorityTone = (priority: GeneralNotificationItem["priority"]) => {
|
||||
if (priority === "URGENT") return "danger";
|
||||
if (priority === "HIGH") return "warning";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const categoryLabel = (category: string) => {
|
||||
if (category.startsWith("RISK_") || category.includes("MONITORING")) return "风险时效";
|
||||
if (category.startsWith("DOCUMENT_")) return "文件回执";
|
||||
if (category.startsWith("MILESTONE_")) return "项目里程碑";
|
||||
if (category.startsWith("VISIT_WINDOW_")) return "访视窗口";
|
||||
if (category.startsWith("COLLABORATION_")) return "在线协作";
|
||||
return "业务通知";
|
||||
};
|
||||
|
||||
const formatDateTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const handleNotificationChange = () => { void loadNotifications(); };
|
||||
|
||||
watch(() => study.currentStudy?.id, resetAndLoad);
|
||||
onMounted(() => {
|
||||
void loadNotifications();
|
||||
window.addEventListener("focus", handleNotificationChange);
|
||||
window.addEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("focus", handleNotificationChange);
|
||||
window.removeEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-page {
|
||||
--notification-accent: var(--ctms-primary, #3f5d75);
|
||||
--notification-accent-soft: rgba(63, 93, 117, 0.1);
|
||||
min-height: 100%;
|
||||
background: var(--ctms-bg-base, #f9fafb);
|
||||
color: var(--ctms-text-main, #172033);
|
||||
}
|
||||
|
||||
.notification-page__canvas {
|
||||
width: min(100%, 1560px);
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(320px, 1fr);
|
||||
gap: 0;
|
||||
align-content: stretch;
|
||||
padding: clamp(10px, 1.4vw, 18px);
|
||||
}
|
||||
|
||||
.notification-page__hero {
|
||||
padding: 6px 4px 12px;
|
||||
border-bottom: 1px solid var(--ctms-border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.notification-page__header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(260px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.notification-page__intro {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-page__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notification-page__title-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid rgba(63, 93, 117, 0.12);
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
box-shadow: 0 4px 12px rgba(38, 72, 98, 0.06);
|
||||
color: var(--notification-accent);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.notification-page__header h1 {
|
||||
margin: 0;
|
||||
color: var(--ctms-text-main, #172033);
|
||||
font-size: 22px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.notification-page__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notification-page__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.notification-summary-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 32px;
|
||||
padding: 0 18px;
|
||||
border-right: 1px solid var(--ctms-border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.notification-summary-item:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.notification-summary-item:last-child {
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.notification-summary-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--ctms-text-secondary, #64748b);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.notification-summary-item--unread .notification-summary-item__icon {
|
||||
color: var(--notification-accent);
|
||||
}
|
||||
|
||||
.notification-summary-item__label {
|
||||
min-width: 0;
|
||||
color: var(--ctms-text-regular, #334155);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.notification-summary-item strong {
|
||||
min-width: 1ch;
|
||||
margin-left: 4px;
|
||||
color: var(--ctms-text-main, #172033);
|
||||
font-size: 19px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 320px;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.notification-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background: transparent;
|
||||
color: var(--ctms-text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-toolbar__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-toolbar__title {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-toolbar__title strong {
|
||||
color: var(--ctms-text-main, #172033);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notification-toolbar :deep(.el-radio-button__inner) {
|
||||
min-height: 28px;
|
||||
padding: 5px 12px;
|
||||
font-weight: 550;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 240px;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 10px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.notification-row:hover {
|
||||
background: var(--ctms-neutral-100, #f5f7fa);
|
||||
box-shadow: inset 3px 0 0 rgba(63, 93, 117, 0.24);
|
||||
}
|
||||
|
||||
.notification-row:focus-visible {
|
||||
outline: 2px solid var(--notification-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.notification-row.is-read {
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.notification-row.is-resolved {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.notification-row__mark {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--notification-accent);
|
||||
box-shadow: 0 0 0 4px var(--notification-accent-soft);
|
||||
}
|
||||
|
||||
.notification-row__mark.is-danger {
|
||||
background: var(--ctms-danger, #c24b4b);
|
||||
box-shadow: 0 0 0 4px rgba(194, 75, 75, 0.1);
|
||||
}
|
||||
|
||||
.notification-row__mark.is-warning {
|
||||
background: var(--ctms-warning, #c58b2a);
|
||||
box-shadow: 0 0 0 4px rgba(197, 139, 42, 0.11);
|
||||
}
|
||||
|
||||
.notification-row__main,
|
||||
.notification-row__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notification-row__main { gap: 6px; }
|
||||
|
||||
.notification-row__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-row__heading strong {
|
||||
color: var(--ctms-text-main, #172033);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.notification-row__message,
|
||||
.notification-row__due,
|
||||
.notification-row__meta {
|
||||
color: var(--ctms-text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-row__message {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notification-row__due {
|
||||
color: #a16207;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.notification-row__meta {
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-row__meta small {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--ctms-neutral-100, #f5f7fa);
|
||||
color: var(--ctms-text-secondary, #64748b);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.notification-row__meta small.is-unread {
|
||||
background: var(--notification-accent-soft);
|
||||
color: var(--notification-accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.notification-row__meta small > span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
display: flex;
|
||||
min-height: 240px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 22px 16px 28px;
|
||||
}
|
||||
|
||||
.notification-empty :deep(.el-empty__image) {
|
||||
opacity: 0.72;
|
||||
filter: saturate(0.55);
|
||||
}
|
||||
|
||||
.notification-empty :deep(.el-empty__description) {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.notification-empty__copy { text-align: center; }
|
||||
|
||||
.notification-empty__copy strong {
|
||||
color: var(--ctms-text-regular, #334155);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.notification-empty :deep(.el-empty__bottom) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.notification-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 14px 20px 18px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .notification-page {
|
||||
--notification-accent-soft: rgba(143, 183, 212, 0.14);
|
||||
background: var(--ctms-bg-base);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .notification-page__title-icon {
|
||||
border-color: rgba(143, 183, 212, 0.16);
|
||||
background: rgba(17, 24, 39, 0.72);
|
||||
}
|
||||
|
||||
:global(.desktop-workbench) .notification-page__canvas {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
:global(.desktop-workbench) .notification-page__hero {
|
||||
padding: 4px 2px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.notification-page__header {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.notification-page__summary {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.notification-page__canvas {
|
||||
grid-template-rows: auto minmax(320px, 1fr);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.notification-page__hero {
|
||||
padding: 4px 2px 10px;
|
||||
}
|
||||
|
||||
.notification-toolbar,
|
||||
.notification-toolbar__main {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.notification-page__header {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notification-page__actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notification-page__actions :deep(.el-button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-page__summary {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.notification-toolbar {
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.notification-toolbar__main {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-toolbar :deep(.el-radio-group) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notification-toolbar :deep(.el-radio-button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-toolbar :deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.notification-row__meta {
|
||||
grid-column: 2;
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.notification-page__title-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.notification-page__header h1 {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.notification-summary-item {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -43,7 +43,7 @@
|
||||
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
|
||||
<span>{{ clientDescription(scope.row) }}</span>
|
||||
<el-tag v-if="scope.row.grouped_count > 1" type="info" effect="plain" size="small">
|
||||
合并 {{ scope.row.grouped_count }} 条
|
||||
累计 {{ scope.row.grouped_count }} 次会话
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
@@ -76,6 +76,10 @@ import { ElMessage } from "element-plus";
|
||||
import { fetchUserLoginActivities } from "../../api/users";
|
||||
import type { UserInfo, UserLoginActivity } from "../../types/api";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import {
|
||||
groupLoginActivitiesBySource,
|
||||
type DisplayLoginActivity,
|
||||
} from "./loginActivitySources";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
@@ -95,8 +99,6 @@ const visibleProxy = computed({
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
|
||||
|
||||
const resolvedActivityStatus = (item: UserLoginActivity) => {
|
||||
if (item.activity_status) return item.activity_status;
|
||||
if (item.ended_at) return "ENDED";
|
||||
@@ -120,30 +122,9 @@ const activityStatusDescription = (item: UserLoginActivity) => {
|
||||
};
|
||||
const clientDescription = (item: UserLoginActivity) =>
|
||||
[item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--";
|
||||
const activitySourceKey = (item: UserLoginActivity) => {
|
||||
if (!item.login_ip) return `session:${item.id}`;
|
||||
return [item.login_ip, item.client_type, item.client_platform || "", item.client_source || ""].join("|");
|
||||
};
|
||||
const sourceActivities = computed<DisplayLoginActivity[]>(() => {
|
||||
const rows: DisplayLoginActivity[] = [];
|
||||
const groupedHistory = new Map<string, DisplayLoginActivity>();
|
||||
activities.value.forEach((item) => {
|
||||
const row: DisplayLoginActivity = { ...item, grouped_count: 1 };
|
||||
if (resolvedActivityStatus(item) === "ONLINE") {
|
||||
rows.push(row);
|
||||
return;
|
||||
}
|
||||
const key = activitySourceKey(item);
|
||||
const existing = groupedHistory.get(key);
|
||||
if (existing) {
|
||||
existing.grouped_count += 1;
|
||||
return;
|
||||
}
|
||||
groupedHistory.set(key, row);
|
||||
rows.push(row);
|
||||
});
|
||||
return rows;
|
||||
});
|
||||
const sourceActivities = computed<DisplayLoginActivity[]>(() =>
|
||||
groupLoginActivitiesBySource(activities.value, resolvedActivityStatus),
|
||||
);
|
||||
const displayActivities = computed<DisplayLoginActivity[]>(() =>
|
||||
activityViewMode.value === "source"
|
||||
? sourceActivities.value
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("admin user login status", () => {
|
||||
expect(drawer).toContain("resolvedActivityStatus");
|
||||
expect(drawer).toContain("最近来源");
|
||||
expect(drawer).toContain("全部会话");
|
||||
expect(drawer).toContain("activitySourceKey");
|
||||
expect(drawer).toContain("groupLoginActivitiesBySource");
|
||||
expect(drawer).toContain("grouped_count");
|
||||
expect(drawer).toContain("scope.row.ended_at");
|
||||
expect(drawer).not.toContain("const isRecent");
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserLoginActivity } from "../../types/api";
|
||||
import { activitySourceKey, groupLoginActivitiesBySource } from "./loginActivitySources";
|
||||
|
||||
const activity = (overrides: Partial<UserLoginActivity>): UserLoginActivity => ({
|
||||
id: "session-1",
|
||||
client_type: "web",
|
||||
client_platform: "macos",
|
||||
client_version: "0.1.0",
|
||||
login_ip: "192.168.97.1",
|
||||
login_at: "2026-07-16T02:23:04Z",
|
||||
last_seen_at: "2026-07-16T03:08:32Z",
|
||||
activity_status: "ENDED",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("login activity sources", () => {
|
||||
it("uses only client type and a recorded IP as the source identity", () => {
|
||||
expect(activitySourceKey(activity({ client_platform: "windows", client_source: "installer" }))).toBe(
|
||||
"web|192.168.97.1",
|
||||
);
|
||||
expect(activitySourceKey(activity({ id: "legacy", login_ip: null }))).toBe("session:legacy");
|
||||
});
|
||||
|
||||
it("merges online and historical sessions from the same client and IP", () => {
|
||||
const rows = groupLoginActivitiesBySource(
|
||||
[
|
||||
activity({
|
||||
id: "current",
|
||||
login_at: "2026-07-16T07:22:42Z",
|
||||
last_seen_at: "2026-07-16T07:53:00Z",
|
||||
activity_status: "ONLINE",
|
||||
ended_at: null,
|
||||
}),
|
||||
activity({ id: "history", ended_at: "2026-07-16T03:08:32Z" }),
|
||||
activity({
|
||||
id: "desktop",
|
||||
client_type: "desktop",
|
||||
login_at: "2026-07-15T08:42:02Z",
|
||||
last_seen_at: "2026-07-16T07:53:42Z",
|
||||
activity_status: "ONLINE",
|
||||
ended_at: null,
|
||||
}),
|
||||
],
|
||||
(item) => item.activity_status!,
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ id: "desktop", grouped_count: 1, activity_status: "ONLINE" });
|
||||
expect(rows[1]).toMatchObject({
|
||||
id: "current",
|
||||
grouped_count: 2,
|
||||
login_at: "2026-07-16T02:23:04Z",
|
||||
last_seen_at: "2026-07-16T07:53:00Z",
|
||||
activity_status: "ONLINE",
|
||||
ended_at: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not merge sessions whose IP was not recorded", () => {
|
||||
const rows = groupLoginActivitiesBySource(
|
||||
[activity({ id: "legacy-1", login_ip: null }), activity({ id: "legacy-2", login_ip: null })],
|
||||
(item) => item.activity_status!,
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { UserLoginActivity } from "../../types/api";
|
||||
|
||||
export type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
|
||||
|
||||
type ActivityStatus = NonNullable<UserLoginActivity["activity_status"]>;
|
||||
|
||||
const activityTime = (value: string) => new Date(value).getTime();
|
||||
|
||||
export const activitySourceKey = (item: UserLoginActivity) => {
|
||||
if (!item.login_ip) return `session:${item.id}`;
|
||||
return [item.client_type, item.login_ip].join("|");
|
||||
};
|
||||
|
||||
export const groupLoginActivitiesBySource = (
|
||||
activities: UserLoginActivity[],
|
||||
resolveStatus: (item: UserLoginActivity) => ActivityStatus,
|
||||
): DisplayLoginActivity[] => {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ row: DisplayLoginActivity; firstLoginAt: string; hasOnlineSession: boolean }
|
||||
>();
|
||||
|
||||
activities.forEach((item) => {
|
||||
const key = activitySourceKey(item);
|
||||
const status = resolveStatus(item);
|
||||
const existing = grouped.get(key);
|
||||
if (!existing) {
|
||||
grouped.set(key, {
|
||||
row: { ...item, activity_status: status, grouped_count: 1 },
|
||||
firstLoginAt: item.login_at,
|
||||
hasOnlineSession: status === "ONLINE",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.row.grouped_count += 1;
|
||||
existing.hasOnlineSession ||= status === "ONLINE";
|
||||
if (activityTime(item.login_at) < activityTime(existing.firstLoginAt)) {
|
||||
existing.firstLoginAt = item.login_at;
|
||||
}
|
||||
if (activityTime(item.last_seen_at) > activityTime(existing.row.last_seen_at)) {
|
||||
const groupedCount = existing.row.grouped_count;
|
||||
existing.row = { ...item, activity_status: status, grouped_count: groupedCount };
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map(({ row, firstLoginAt, hasOnlineSession }) => ({
|
||||
...row,
|
||||
login_at: firstLoginAt,
|
||||
activity_status: hasOnlineSession ? "ONLINE" : row.activity_status,
|
||||
ended_at: hasOnlineSession ? null : row.ended_at,
|
||||
end_reason: hasOnlineSession ? null : row.end_reason,
|
||||
}))
|
||||
.sort((left, right) => activityTime(right.last_seen_at) - activityTime(left.last_seen_at));
|
||||
};
|
||||
@@ -381,7 +381,7 @@ import StateLoading from "../../components/StateLoading.vue";
|
||||
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
||||
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
|
||||
import { getContentDispositionFilename } from "../../utils/contentDisposition";
|
||||
import { getContentDispositionFilename, getHttpHeaderString } from "../../utils/contentDisposition";
|
||||
import { prepareSaveFile } from "../../runtime";
|
||||
import { detectFilePreviewKind, ONLYOFFICE_FILE_EXTENSIONS } from "../../utils/officePreview";
|
||||
|
||||
@@ -803,7 +803,8 @@ const previewVersion = async (version: DocumentVersion) => {
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const response = await downloadDocumentVersion(version.id);
|
||||
const contentType = response.headers?.["content-type"] || version.mime_type || "application/octet-stream";
|
||||
const contentType =
|
||||
getHttpHeaderString(response.headers?.["content-type"]) || version.mime_type || "application/octet-stream";
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
previewObjectUrl.value = URL.createObjectURL(blob);
|
||||
previewUrl.value = previewObjectUrl.value;
|
||||
@@ -823,8 +824,9 @@ const downloadVersion = async (version: DocumentVersion) => {
|
||||
return;
|
||||
}
|
||||
const response = await downloadDocumentVersion(version.id);
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getContentDispositionFilename(response.headers?.["content-disposition"]) || suggestedName;
|
||||
const contentType = getHttpHeaderString(response.headers?.["content-type"]) || "application/octet-stream";
|
||||
const filename =
|
||||
getContentDispositionFilename(getHttpHeaderString(response.headers?.["content-disposition"])) || suggestedName;
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob }, {}, destination);
|
||||
} catch (e: any) { ElMessage.error(await getApiErrorMessage(e, TEXT.common.messages.downloadFailed)); }
|
||||
@@ -840,10 +842,11 @@ const openVersion = async (version: DocumentVersion) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getContentDispositionFilename(response.headers?.["content-disposition"])
|
||||
|| getVersionFileName(version)
|
||||
|| `document-${version.version_no || version.id}.bin`;
|
||||
const contentType = getHttpHeaderString(response.headers?.["content-type"]) || "application/octet-stream";
|
||||
const filename =
|
||||
getContentDispositionFilename(getHttpHeaderString(response.headers?.["content-disposition"])) ||
|
||||
getVersionFileName(version) ||
|
||||
`document-${version.version_no || version.id}.bin`;
|
||||
await openFileWithFeedback({
|
||||
suggestedName: filename,
|
||||
mimeType: contentType,
|
||||
|
||||
@@ -474,6 +474,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||
import { getContentDispositionFilename, getHttpHeaderString } from "../../utils/contentDisposition";
|
||||
|
||||
interface MonitoringIssueRow {
|
||||
id: string;
|
||||
@@ -937,13 +938,6 @@ const removeIssue = async (row: MonitoringIssueRow) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getFilename = (header?: string | null) => {
|
||||
if (!header) return null;
|
||||
const match = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(header);
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match[1] || match[2] || "");
|
||||
};
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || !canListIssues.value) return;
|
||||
@@ -975,8 +969,9 @@ const handleExportExcel = async () => {
|
||||
}
|
||||
|
||||
const response = await exportMonitoringVisitIssues(studyId, params);
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
|
||||
const contentType = getHttpHeaderString(response.headers?.["content-type"]) || "application/octet-stream";
|
||||
const filename =
|
||||
getContentDispositionFilename(getHttpHeaderString(response.headers?.["content-disposition"])) || "监查访视问题.xlsx";
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
await saveFileWithFeedback(
|
||||
{ suggestedName: filename, mimeType: contentType, data: blob },
|
||||
|
||||
+10
-5
@@ -309,7 +309,8 @@ ctms_require_docker_compose() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 安装/更新所需依赖。openssl、curl 仅在非 dev 环境要求(dev 用默认密钥、不做 RSA 生成)。
|
||||
# 安装/更新所需依赖。所有环境都需要 openssl 生成独立的 ONLYOFFICE JWT 密钥;
|
||||
# main/release 还会用它生成登录 RSA 密钥。
|
||||
ctms_require_dependencies() {
|
||||
local env="$1"
|
||||
ctms_require_env "$env"
|
||||
@@ -317,10 +318,7 @@ ctms_require_dependencies() {
|
||||
|
||||
ctms_require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1
|
||||
ctms_require_command curl "Linux: sudo apt-get install -y curl | macOS: brew install curl" || missing=1
|
||||
# openssl 仅非 dev 需要:dev 用 dev-secret 与空 RSA key,不生成密钥
|
||||
if [[ "$env" != "dev" ]]; then
|
||||
ctms_require_command openssl "Linux: sudo apt-get install -y openssl | macOS: brew install openssl" || missing=1
|
||||
fi
|
||||
ctms_require_command openssl "Linux: sudo apt-get install -y openssl | macOS: brew install openssl" || missing=1
|
||||
[[ "$missing" -eq 0 ]] || ctms_fail "系统依赖不完整,请按上方提示安装后重试"
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
@@ -541,6 +539,7 @@ ctms_upsert_env_value() {
|
||||
ctms_write_env_file() {
|
||||
local project_name="$1" runtime_env="$2" login_key_id="$3"
|
||||
local jwt_secret="$4" rsa_private_key="$5"
|
||||
local onlyoffice_jwt_secret="$6" onlyoffice_instance_id="$7"
|
||||
local allow_insecure_dev_login="false"
|
||||
[[ "$runtime_env" == "development" ]] && allow_insecure_dev_login="true"
|
||||
local temp_file
|
||||
@@ -554,6 +553,12 @@ ctms_write_env_file() {
|
||||
printf 'JWT_SECRET_KEY=%s\n' "$jwt_secret"
|
||||
printf 'LOGIN_RSA_KEY_ID=%s\n' "$login_key_id"
|
||||
printf 'LOGIN_RSA_PRIVATE_KEY=%s\n' "$rsa_private_key"
|
||||
printf 'ONLYOFFICE_ENABLED=true\n'
|
||||
printf 'ONLYOFFICE_JWT_SECRET=%s\n' "$onlyoffice_jwt_secret"
|
||||
printf 'ONLYOFFICE_INTERNAL_URL=http://onlyoffice\n'
|
||||
printf 'ONLYOFFICE_STORAGE_BASE_URL=http://backend:8000\n'
|
||||
printf 'ONLYOFFICE_INSTANCE_ID=%s\n' "$onlyoffice_instance_id"
|
||||
printf 'ONLYOFFICE_CONFIG_TTL_SECONDS=300\n'
|
||||
} >> "$temp_file"
|
||||
|
||||
mv "$temp_file" "$CTMS_ENV_FILE"
|
||||
|
||||
+59
-6
@@ -173,6 +173,10 @@ generate_jwt_secret() {
|
||||
openssl rand -hex 32
|
||||
}
|
||||
|
||||
generate_onlyoffice_instance_id() {
|
||||
printf 'ctms-%s-%s' "$TARGET_ENV" "$(openssl rand -hex 8)"
|
||||
}
|
||||
|
||||
# ── 地址解析 ─────────────────────────────────
|
||||
resolve_base_url() {
|
||||
[[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
|
||||
@@ -221,6 +225,7 @@ confirm_install() {
|
||||
row "健康检查地址" "$BASE_URL" "${CC_INFO}"
|
||||
row ".env 文件" "$env_status"
|
||||
row "pg_data 目录" "$pg_status"
|
||||
row "ONLYOFFICE" "标准组件(自动安装)" "${CC_INFO}"
|
||||
row "镜像构建" "$build_status"
|
||||
row "数据库迁移" "$migrate_status"
|
||||
ctms_box_bottom "${CC_PRIMARY}${C_BOLD}" "$w"
|
||||
@@ -252,6 +257,7 @@ prepare_env_file() {
|
||||
step "准备环境配置文件"
|
||||
local project_name="$1" runtime_env="$2" login_key_id="$3"
|
||||
local jwt_secret="" rsa_private_key=""
|
||||
local onlyoffice_jwt_secret="" onlyoffice_instance_id=""
|
||||
|
||||
# 首次安装:无 .env,写全量。
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
@@ -265,7 +271,12 @@ prepare_env_file() {
|
||||
rsa_private_key="$(generate_escaped_private_key)"
|
||||
ok "已生成新的 LOGIN_RSA_PRIVATE_KEY"
|
||||
fi
|
||||
ctms_write_env_file "$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key"
|
||||
onlyoffice_jwt_secret="$(generate_jwt_secret)"
|
||||
onlyoffice_instance_id="$(generate_onlyoffice_instance_id)"
|
||||
ok "已生成独立的 ONLYOFFICE JWT 密钥与实例标识"
|
||||
ctms_write_env_file \
|
||||
"$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key" \
|
||||
"$onlyoffice_jwt_secret" "$onlyoffice_instance_id"
|
||||
ok ".env 写入完成"
|
||||
return
|
||||
fi
|
||||
@@ -310,6 +321,36 @@ prepare_env_file() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# ONLYOFFICE 是标准组件:旧环境升级时自动启用并补齐独立安全配置。
|
||||
if [[ "$(read_env_value ONLYOFFICE_ENABLED || true)" != "true" ]]; then
|
||||
ctms_upsert_env_value ONLYOFFICE_ENABLED true; repaired=1
|
||||
warn ".env 中 ONLYOFFICE 未启用,已作为标准组件启用"
|
||||
fi
|
||||
|
||||
onlyoffice_jwt_secret="$(read_env_value ONLYOFFICE_JWT_SECRET || true)"
|
||||
jwt_secret="$(read_env_value JWT_SECRET_KEY || true)"
|
||||
if [[ ${#onlyoffice_jwt_secret} -lt 32 || "$onlyoffice_jwt_secret" == "$jwt_secret" ]]; then
|
||||
onlyoffice_jwt_secret="$(generate_jwt_secret)"
|
||||
ctms_upsert_env_value ONLYOFFICE_JWT_SECRET "$onlyoffice_jwt_secret"; repaired=1
|
||||
warn ".env 缺少有效的独立 ONLYOFFICE_JWT_SECRET,已生成并补全"
|
||||
fi
|
||||
|
||||
if [[ -z "$(read_env_value ONLYOFFICE_INSTANCE_ID || true)" ]]; then
|
||||
onlyoffice_instance_id="$(generate_onlyoffice_instance_id)"
|
||||
ctms_upsert_env_value ONLYOFFICE_INSTANCE_ID "$onlyoffice_instance_id"; repaired=1
|
||||
warn ".env 缺少 ONLYOFFICE_INSTANCE_ID,已生成稳定实例标识"
|
||||
fi
|
||||
|
||||
if [[ -z "$(read_env_value ONLYOFFICE_INTERNAL_URL || true)" ]]; then
|
||||
ctms_upsert_env_value ONLYOFFICE_INTERNAL_URL http://onlyoffice; repaired=1
|
||||
fi
|
||||
if [[ -z "$(read_env_value ONLYOFFICE_STORAGE_BASE_URL || true)" ]]; then
|
||||
ctms_upsert_env_value ONLYOFFICE_STORAGE_BASE_URL http://backend:8000; repaired=1
|
||||
fi
|
||||
if [[ -z "$(read_env_value ONLYOFFICE_CONFIG_TTL_SECONDS || true)" ]]; then
|
||||
ctms_upsert_env_value ONLYOFFICE_CONFIG_TTL_SECONDS 300; repaired=1
|
||||
fi
|
||||
|
||||
if [[ "$repaired" -eq 1 ]]; then
|
||||
ok ".env 已补全缺失项"
|
||||
else
|
||||
@@ -320,7 +361,8 @@ prepare_env_file() {
|
||||
# ── Docker 流程 ───────────────────────────────
|
||||
run_compose_config() {
|
||||
step "校验 Docker Compose 配置"
|
||||
ctms_run_quiet "Docker Compose 配置语法校验" -- compose_cmd config
|
||||
# config 会展开并输出 JWT/RSA 等敏感环境变量;即使启用 --verbose 也只做静默校验。
|
||||
ctms_run_quiet "Docker Compose 配置语法校验" -- compose_cmd config --quiet
|
||||
}
|
||||
|
||||
run_backend_init() {
|
||||
@@ -338,14 +380,14 @@ run_build_and_start() {
|
||||
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
||||
step "启动服务(跳过镜像构建)"
|
||||
if [[ "$TARGET_ENV" == "dev" ]]; then
|
||||
ctms_run_quiet "启动开发容器并刷新后端/Nginx/Vite" -- compose_cmd up -d --force-recreate backend nginx frontend-dev
|
||||
ctms_run_quiet "启动开发容器并刷新后端/ONLYOFFICE/Nginx/Vite" -- compose_cmd up -d --force-recreate backend onlyoffice nginx frontend-dev
|
||||
else
|
||||
ctms_run_quiet "启动容器" -- compose_cmd up -d
|
||||
fi
|
||||
else
|
||||
step "构建镜像并启动服务"
|
||||
if [[ "$TARGET_ENV" == "dev" ]]; then
|
||||
CTMS_LIVE_OUTPUT=1 ctms_run_quiet "构建并刷新开发容器(首次较慢)" -- compose_cmd up -d --build --force-recreate backend nginx frontend-dev
|
||||
CTMS_LIVE_OUTPUT=1 ctms_run_quiet "构建并刷新开发容器(首次较慢)" -- compose_cmd up -d --build --force-recreate backend onlyoffice nginx frontend-dev
|
||||
else
|
||||
CTMS_LIVE_OUTPUT=1 ctms_run_quiet "构建镜像并启动容器(首次较慢)" -- compose_cmd up -d --build
|
||||
fi
|
||||
@@ -366,7 +408,7 @@ check_container_status() {
|
||||
step "检查容器运行状态"
|
||||
local running
|
||||
running="$(compose_cmd ps --services --filter status=running 2>/dev/null || true)"
|
||||
local services=(db backend nginx)
|
||||
local services=(db backend onlyoffice nginx)
|
||||
[[ "$TARGET_ENV" != "dev" ]] || services+=(frontend-dev)
|
||||
local svc
|
||||
for svc in "${services[@]}"; do
|
||||
@@ -398,6 +440,14 @@ if settings.LOGIN_RSA_KEY_ID != expected_key_id:
|
||||
sys.exit("LOGIN_RSA_KEY_ID 不匹配")
|
||||
if expected_env == "production" and settings.JWT_SECRET_KEY == "dev-secret":
|
||||
sys.exit("生产环境不允许使用默认 JWT_SECRET_KEY")
|
||||
if not settings.ONLYOFFICE_ENABLED:
|
||||
sys.exit("ONLYOFFICE 必须作为标准组件启用")
|
||||
if len(settings.ONLYOFFICE_JWT_SECRET or "") < 32:
|
||||
sys.exit("ONLYOFFICE_JWT_SECRET 未正确配置")
|
||||
if settings.ONLYOFFICE_JWT_SECRET == settings.JWT_SECRET_KEY:
|
||||
sys.exit("ONLYOFFICE_JWT_SECRET 不得复用登录 JWT 密钥")
|
||||
if not settings.ONLYOFFICE_INSTANCE_ID:
|
||||
sys.exit("ONLYOFFICE_INSTANCE_ID 未配置")
|
||||
if expect_rsa:
|
||||
if not settings.LOGIN_RSA_PRIVATE_KEY:
|
||||
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
|
||||
@@ -463,6 +513,7 @@ run_health_checks() {
|
||||
check_http_endpoint "/health"
|
||||
check_http_endpoint "/readyz"
|
||||
check_http_endpoint "/api/v1/auth/login-key"
|
||||
check_http_endpoint "/onlyoffice/healthcheck" 120
|
||||
if [[ "$TARGET_ENV" == "dev" ]]; then
|
||||
check_http_endpoint "/" 60
|
||||
fi
|
||||
@@ -515,4 +566,6 @@ main() {
|
||||
show_success
|
||||
}
|
||||
|
||||
main "$@"
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
|
||||
@@ -80,14 +80,13 @@ main() {
|
||||
docker compose
|
||||
-f "$CTMS_ROOT_DIR/docker-compose.dev.yaml"
|
||||
-p ctms_dev
|
||||
--profile office
|
||||
)
|
||||
|
||||
ctms_step "校验开发 Compose 配置"
|
||||
"${compose[@]}" config --quiet
|
||||
ctms_ok "Compose 配置有效"
|
||||
|
||||
ctms_step "构建并启动 Office 预览服务"
|
||||
ctms_step "重新构建并启动标准 ONLYOFFICE 服务"
|
||||
"${compose[@]}" up -d --build --force-recreate backend onlyoffice nginx
|
||||
|
||||
ctms_step "验证运行状态"
|
||||
|
||||
Reference in New Issue
Block a user