Compare commits
71 Commits
b1c55abb10
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 795e0a75ca | |||
| e3489f3b06 | |||
| c23a5d6a22 | |||
| fa26c84bd2 | |||
| c5c9bb5527 | |||
| feb1f3db65 | |||
| de3dc87920 | |||
| a0aa9384d7 | |||
| a938d2ed5f | |||
| 4863ade45b | |||
| d5279b124f | |||
| 1837ceff58 | |||
| 3e77127687 | |||
| 88bd0c5942 | |||
| 6b55c18610 | |||
| 1d26646a96 | |||
| c68dddfc01 | |||
| 44db5db838 | |||
| ab59476d10 | |||
| b26ebdda02 | |||
| f2fa10c178 | |||
| f11a5c84d9 | |||
| 400c9be3a7 | |||
| 5ea9b24912 | |||
| aed628a49b | |||
| 0bbc125806 | |||
| 878d1dc701 | |||
| 0c187a35fa | |||
| 1dc10f569d | |||
| fd4208d06f | |||
| 0aceb1fd74 | |||
| 141fea674f | |||
| 6b980f884a | |||
| 0841eb0f79 | |||
| f4844aa8f1 | |||
| b03e30d0dd | |||
| 0aff10fe0d | |||
| 76f2d9f22a | |||
| b73f23c1eb | |||
| e7b18758b2 | |||
| 84d5daebab | |||
| 3a866accd9 | |||
| a17fa618cd | |||
| 0d03e1656a | |||
| 1c1527a224 | |||
| 360988de5e | |||
| b8c5c4123a | |||
| 8c8327df92 | |||
| 32167fba02 | |||
| 593c60c782 | |||
| 18a3166463 | |||
| 45ed1d7f36 | |||
| 2d7d13b2f5 | |||
| 5ee84c97e8 | |||
| b491b6a146 | |||
| 9cac75e85c | |||
| c923f887a0 | |||
| 628ff8828b | |||
| 7c721d4e5c | |||
| d1a6c957f7 | |||
| 4654a812a0 | |||
| b283cf1e5c | |||
| 18daea7f2c | |||
| 1078b67de6 | |||
| 668e719b6d | |||
| b25055775e | |||
| 6c2bcc59b2 | |||
| e242e067f0 | |||
| c7ca97a6d6 | |||
| f94acebd1a | |||
| f9a9f7eafe |
@@ -0,0 +1,167 @@
|
|||||||
|
name: Client Quality Gates
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "AGENTS.md"
|
||||||
|
- "frontend/**"
|
||||||
|
- ".github/workflows/client-quality-gates.yml"
|
||||||
|
- ".github/workflows/desktop-release-candidate.yml"
|
||||||
|
- ".github/workflows/desktop-windows-internal.yml"
|
||||||
|
- "docs/branch-governance.md"
|
||||||
|
- "docs/desktop-project-plan.md"
|
||||||
|
- "docs/desktop-phase-1-design.md"
|
||||||
|
- "docs/desktop-phase-2-design.md"
|
||||||
|
- "docs/audits/desktop-release-stabilization-checklist.md"
|
||||||
|
- "docs/guides/client-release.md"
|
||||||
|
- "docs/guides/branch-maintenance-sop-zh.md"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
- main
|
||||||
|
- release
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
paths:
|
||||||
|
- "AGENTS.md"
|
||||||
|
- "frontend/**"
|
||||||
|
- ".github/workflows/client-quality-gates.yml"
|
||||||
|
- ".github/workflows/desktop-release-candidate.yml"
|
||||||
|
- ".github/workflows/desktop-windows-internal.yml"
|
||||||
|
- "docs/branch-governance.md"
|
||||||
|
- "docs/desktop-project-plan.md"
|
||||||
|
- "docs/desktop-phase-1-design.md"
|
||||||
|
- "docs/desktop-phase-2-design.md"
|
||||||
|
- "docs/audits/desktop-release-stabilization-checklist.md"
|
||||||
|
- "docs/guides/client-release.md"
|
||||||
|
- "docs/guides/branch-maintenance-sop-zh.md"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
web:
|
||||||
|
name: Shared client and Web
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.13"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Resolve build metadata
|
||||||
|
id: build-metadata
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||||
|
channel="release"
|
||||||
|
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
|
||||||
|
channel="${GITHUB_REF_NAME}"
|
||||||
|
else
|
||||||
|
channel="dev"
|
||||||
|
fi
|
||||||
|
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Check release build metadata
|
||||||
|
run: npm run release:env:check
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|
||||||
|
- name: Check synchronized client version
|
||||||
|
run: npm run version:check
|
||||||
|
|
||||||
|
- name: Check runtime boundary
|
||||||
|
run: npm run runtime:check
|
||||||
|
|
||||||
|
- name: Check desktop release and security gate
|
||||||
|
run: npm run desktop:release:check
|
||||||
|
|
||||||
|
- name: Check UI contract
|
||||||
|
run: npm run ui:contract
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npm run type-check
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm run test:unit
|
||||||
|
|
||||||
|
- name: Build Web artifact
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|
||||||
|
desktop:
|
||||||
|
name: macOS Desktop
|
||||||
|
runs-on: macos-latest
|
||||||
|
env:
|
||||||
|
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.13"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Resolve build metadata
|
||||||
|
id: build-metadata
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||||
|
channel="release"
|
||||||
|
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
|
||||||
|
channel="${GITHUB_REF_NAME}"
|
||||||
|
else
|
||||||
|
channel="dev"
|
||||||
|
fi
|
||||||
|
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Check release build metadata
|
||||||
|
run: npm run release:env:check
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|
||||||
|
- name: Check synchronized client version
|
||||||
|
run: npm run version:check
|
||||||
|
|
||||||
|
- name: Check runtime boundary
|
||||||
|
run: npm run runtime:check
|
||||||
|
|
||||||
|
- name: Check desktop release and security gate
|
||||||
|
run: npm run desktop:release:check
|
||||||
|
|
||||||
|
- name: Check UI contract
|
||||||
|
run: npm run ui:contract
|
||||||
|
|
||||||
|
- name: Build unsigned macOS application smoke artifact
|
||||||
|
run: npm run desktop:build:app
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
name: Desktop Release Candidate
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
artifact_base_url:
|
||||||
|
description: "Versioned HTTPS prefix for immutable desktop artifacts, for example https://ctms.example.com/desktop-updates/stable/v0.1.0/"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: desktop-release-candidate-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-policy:
|
||||||
|
name: Resolve desktop release signing policy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
platform_signing_mode: ${{ steps.policy.outputs.platform_signing_mode }}
|
||||||
|
macos_signing: ${{ steps.policy.outputs.macos_signing }}
|
||||||
|
windows_signing: ${{ steps.policy.outputs.windows_signing }}
|
||||||
|
artifact_label: ${{ steps.policy.outputs.artifact_label }}
|
||||||
|
warning_required: ${{ steps.policy.outputs.warning_required }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout release source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.13"
|
||||||
|
|
||||||
|
- name: Resolve version-scoped signing policy
|
||||||
|
id: policy
|
||||||
|
shell: bash
|
||||||
|
run: npm run desktop:release-policy:check -- --require-tag --github-output "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
macos-release-candidate:
|
||||||
|
name: macOS release candidate
|
||||||
|
needs: release-policy
|
||||||
|
runs-on: macos-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: release
|
||||||
|
VITE_BUILD_COMMIT: ${{ github.sha }}
|
||||||
|
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||||
|
RELEASE_BUILD: "true"
|
||||||
|
DESKTOP_RELEASE_PLATFORM: macos
|
||||||
|
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
|
||||||
|
REQUIRE_UPDATER_SIGNING: "true"
|
||||||
|
REQUIRE_DESKTOP_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
|
||||||
|
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
|
||||||
|
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout release source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.13"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Enforce release tag context
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
||||||
|
echo "Desktop release candidates must run from a vX.Y.Z tag."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
expected_tag="v$(node -p "require('./package.json').version")"
|
||||||
|
if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then
|
||||||
|
echo "Release tag ${GITHUB_REF_NAME} does not match package version ${expected_tag}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Audit dependencies
|
||||||
|
run: npm audit
|
||||||
|
|
||||||
|
- name: Check release build metadata and signing environment
|
||||||
|
run: npm run release:env:check
|
||||||
|
|
||||||
|
- name: Check desktop release readiness
|
||||||
|
run: npm run desktop:release-readiness:check
|
||||||
|
|
||||||
|
- name: Check synchronized client version
|
||||||
|
run: npm run version:check
|
||||||
|
|
||||||
|
- name: Check runtime boundary
|
||||||
|
run: npm run runtime:check
|
||||||
|
|
||||||
|
- name: Check desktop release and security gate
|
||||||
|
run: npm run desktop:release:check
|
||||||
|
|
||||||
|
- name: Check UI contract
|
||||||
|
run: npm run ui:contract
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npm run type-check
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm run test:unit
|
||||||
|
|
||||||
|
- name: Build Web artifact
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Apple-signed and notarized Universal macOS artifacts
|
||||||
|
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
||||||
|
run: npm run desktop:build:macos-release -- --ci
|
||||||
|
|
||||||
|
- name: Build ad-hoc Universal macOS artifacts
|
||||||
|
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
|
||||||
|
run: npm run desktop:build:macos-unsigned-release -- --ci
|
||||||
|
|
||||||
|
- name: Verify and stage macOS artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
app="$(find src-tauri/target -path '*/release/bundle/macos/*.app' -print -quit)"
|
||||||
|
artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)"
|
||||||
|
dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
|
||||||
|
if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then
|
||||||
|
echo "macOS app, updater artifact/signature, and DMG are all required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
codesign --verify --deep --strict --verbose=2 "${app}"
|
||||||
|
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "signed" ]]; then
|
||||||
|
spctl --assess --type execute --verbose=2 "${app}"
|
||||||
|
xcrun stapler validate "${app}"
|
||||||
|
xcrun stapler validate "${dmg}"
|
||||||
|
else
|
||||||
|
signature_info="$(codesign -dv --verbose=4 "${app}" 2>&1)"
|
||||||
|
if ! grep -q "Signature=adhoc" <<<"${signature_info}"; then
|
||||||
|
echo "The macOS unsigned-platform exception must produce an ad-hoc signed app."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
stage="src-tauri/target/desktop-release-macos"
|
||||||
|
mkdir -p "${stage}"
|
||||||
|
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "unsigned-exception" ]]; then
|
||||||
|
artifact_name="$(basename "${artifact}" .app.tar.gz)_UNSIGNED.app.tar.gz"
|
||||||
|
dmg_name="$(basename "${dmg}" .dmg)_UNSIGNED.dmg"
|
||||||
|
cp "${artifact}" "${stage}/${artifact_name}"
|
||||||
|
cp "${artifact}.sig" "${stage}/${artifact_name}.sig"
|
||||||
|
cp "${dmg}" "${stage}/${dmg_name}"
|
||||||
|
cp desktop-release-unsigned-warning.txt "${stage}/UNSIGNED-PLATFORM-RELEASE.txt"
|
||||||
|
else
|
||||||
|
cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload macOS candidate artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||||
|
path: frontend/src-tauri/target/desktop-release-macos/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
windows-release-candidate:
|
||||||
|
name: Windows release candidate
|
||||||
|
needs: release-policy
|
||||||
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: release
|
||||||
|
VITE_BUILD_COMMIT: ${{ github.sha }}
|
||||||
|
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||||
|
RELEASE_BUILD: "true"
|
||||||
|
DESKTOP_RELEASE_PLATFORM: windows
|
||||||
|
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
|
||||||
|
REQUIRE_UPDATER_SIGNING: "true"
|
||||||
|
REQUIRE_WINDOWS_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
|
||||||
|
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
|
||||||
|
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||||
|
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||||
|
WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout release source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.13"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Enforce release tag context
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if ($env:GITHUB_REF_TYPE -ne "tag") {
|
||||||
|
throw "Desktop release candidates must run from a vX.Y.Z tag."
|
||||||
|
}
|
||||||
|
$expectedTag = "v$(node -p "require('./package.json').version")"
|
||||||
|
if ($env:GITHUB_REF_NAME -ne $expectedTag) {
|
||||||
|
throw "Release tag $env:GITHUB_REF_NAME does not match package version $expectedTag."
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Audit dependencies
|
||||||
|
run: npm audit
|
||||||
|
|
||||||
|
- name: Check release build metadata and signing environment
|
||||||
|
run: npm run release:env:check
|
||||||
|
|
||||||
|
- name: Check desktop release readiness
|
||||||
|
run: npm run desktop:release-readiness:check
|
||||||
|
|
||||||
|
- name: Check synchronized client version
|
||||||
|
run: npm run version:check
|
||||||
|
|
||||||
|
- name: Check runtime boundary
|
||||||
|
run: npm run runtime:check
|
||||||
|
|
||||||
|
- name: Check desktop release and security gate
|
||||||
|
run: npm run desktop:release:check
|
||||||
|
|
||||||
|
- name: Check UI contract
|
||||||
|
run: npm run ui:contract
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npm run type-check
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm run test:unit
|
||||||
|
|
||||||
|
- name: Build Web artifact
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Import Windows code-signing certificate
|
||||||
|
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx"
|
||||||
|
$encodedCertificate = $env:WINDOWS_CERTIFICATE `
|
||||||
|
-replace "-----BEGIN [^-]+-----", "" `
|
||||||
|
-replace "-----END [^-]+-----", "" `
|
||||||
|
-replace "\s", ""
|
||||||
|
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($encodedCertificate))
|
||||||
|
$password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
|
||||||
|
$importedCertificates = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\CurrentUser\My" -Password $password -Exportable:$false
|
||||||
|
$certificate = $importedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1
|
||||||
|
if (-not $certificate -or -not $certificate.HasPrivateKey) {
|
||||||
|
throw "The imported Windows code-signing certificate is missing its private key."
|
||||||
|
}
|
||||||
|
$now = Get-Date
|
||||||
|
if ($certificate.NotBefore -gt $now -or $certificate.NotAfter -le $now) {
|
||||||
|
throw "The Windows code-signing certificate is not currently valid."
|
||||||
|
}
|
||||||
|
$codeSigningEku = $certificate.EnhancedKeyUsageList | Where-Object { $_.ObjectId.Value -eq "1.3.6.1.5.5.7.3.3" }
|
||||||
|
if (-not $codeSigningEku) {
|
||||||
|
throw "The Windows certificate does not contain the Code Signing enhanced key usage."
|
||||||
|
}
|
||||||
|
"WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||||
|
Remove-Item $pfxPath -Force
|
||||||
|
|
||||||
|
- name: Write signed Windows Tauri config
|
||||||
|
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$config = @{
|
||||||
|
bundle = @{
|
||||||
|
windows = @{
|
||||||
|
certificateThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT
|
||||||
|
digestAlgorithm = "sha256"
|
||||||
|
timestampUrl = $env:WINDOWS_TIMESTAMP_URL
|
||||||
|
tsp = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json -Depth 8
|
||||||
|
Set-Content -Path "tauri.windows.release.conf.json" -Value $config -Encoding utf8
|
||||||
|
Get-Content "tauri.windows.release.conf.json"
|
||||||
|
|
||||||
|
- name: Build signed Windows NSIS artifacts
|
||||||
|
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
||||||
|
timeout-minutes: 30
|
||||||
|
run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci
|
||||||
|
|
||||||
|
- name: Build unsigned Windows NSIS artifacts
|
||||||
|
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
|
||||||
|
timeout-minutes: 30
|
||||||
|
run: npm run desktop:build:windows-release -- --ci
|
||||||
|
|
||||||
|
- name: Verify platform signing mode and stage Windows artifacts
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$bundleDir = "src-tauri/target/release/bundle/nsis"
|
||||||
|
$installers = @(Get-ChildItem -Path $bundleDir -Filter "*.exe" -File)
|
||||||
|
$updaters = @(Get-ChildItem -Path $bundleDir -Filter "*.nsis.zip" -File)
|
||||||
|
$appExecutables = @(Get-ChildItem -Path "src-tauri/target/release" -Filter "*.exe" -File)
|
||||||
|
if ($installers.Count -ne 1 -or $updaters.Count -ne 1 -or $appExecutables.Count -eq 0) {
|
||||||
|
throw "Exactly one NSIS installer/updater and at least one application executable are required."
|
||||||
|
}
|
||||||
|
|
||||||
|
$signaturePath = "$($updaters[0].FullName).sig"
|
||||||
|
if (-not (Test-Path $signaturePath) -or (Get-Item $signaturePath).Length -eq 0) {
|
||||||
|
throw "The Windows updater signature is missing or empty."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($executable in @($appExecutables + $installers)) {
|
||||||
|
$signature = Get-AuthenticodeSignature -FilePath $executable.FullName
|
||||||
|
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "signed") {
|
||||||
|
if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) {
|
||||||
|
throw "Authenticode signature or RFC 3161 timestamp is invalid for $($executable.FullName): $($signature.StatusMessage)"
|
||||||
|
}
|
||||||
|
} elseif ($signature.Status -ne "NotSigned") {
|
||||||
|
throw "The Windows unsigned-platform exception must produce an Authenticode-unsigned executable: $($executable.FullName) returned $($signature.Status)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stage = "src-tauri/target/desktop-release-windows"
|
||||||
|
New-Item -ItemType Directory -Path $stage -Force | Out-Null
|
||||||
|
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "unsigned-exception") {
|
||||||
|
$installerName = $installers[0].Name -replace '\.exe$', '_UNSIGNED.exe'
|
||||||
|
$updaterName = $updaters[0].Name -replace '\.nsis\.zip$', '_UNSIGNED.nsis.zip'
|
||||||
|
Copy-Item $installers[0].FullName -Destination (Join-Path $stage $installerName)
|
||||||
|
Copy-Item $updaters[0].FullName -Destination (Join-Path $stage $updaterName)
|
||||||
|
Copy-Item $signaturePath -Destination (Join-Path $stage "$updaterName.sig")
|
||||||
|
Copy-Item "desktop-release-unsigned-warning.txt" -Destination (Join-Path $stage "UNSIGNED-PLATFORM-RELEASE.txt")
|
||||||
|
} else {
|
||||||
|
Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Remove Windows signing certificate
|
||||||
|
if: always()
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) {
|
||||||
|
Remove-Item "Cert:\CurrentUser\My\$env:WINDOWS_CERTIFICATE_THUMBPRINT" -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
- name: Upload Windows candidate artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||||
|
path: frontend/src-tauri/target/desktop-release-windows/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
aggregate-release-candidate:
|
||||||
|
name: Verify combined desktop release directory
|
||||||
|
needs:
|
||||||
|
- release-policy
|
||||||
|
- macos-release-candidate
|
||||||
|
- windows-release-candidate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout release source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.13"
|
||||||
|
|
||||||
|
- name: Download macOS candidate artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||||
|
path: frontend/src-tauri/target/desktop-release-input/macos
|
||||||
|
|
||||||
|
- name: Download Windows candidate artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||||
|
path: frontend/src-tauri/target/desktop-release-input/windows
|
||||||
|
|
||||||
|
- name: Create desktop release provenance
|
||||||
|
run: npm run desktop:release-provenance:create -- --macos-signing "${{ needs.release-policy.outputs.macos_signing }}" --windows-signing "${{ needs.release-policy.outputs.windows_signing }}" --output src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json
|
||||||
|
|
||||||
|
- name: Create combined desktop update feed
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mapfile -t mac_artifacts < <(find src-tauri/target/desktop-release-input/macos -name '*.app.tar.gz' -type f)
|
||||||
|
mapfile -t dmgs < <(find src-tauri/target/desktop-release-input/macos -name '*.dmg' -type f)
|
||||||
|
mapfile -t windows_artifacts < <(find src-tauri/target/desktop-release-input/windows -name '*.nsis.zip' -type f)
|
||||||
|
mapfile -t installers < <(find src-tauri/target/desktop-release-input/windows -name '*.exe' -type f)
|
||||||
|
if [[ ${#mac_artifacts[@]} -ne 1 || ${#dmgs[@]} -ne 1 || ${#windows_artifacts[@]} -ne 1 || ${#installers[@]} -ne 1 ]]; then
|
||||||
|
echo "Exactly one macOS updater/DMG and one Windows updater/installer are required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
include_args=(--include "${dmgs[0]}" --include "${installers[0]}" --include src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json)
|
||||||
|
notes_args=()
|
||||||
|
if [[ "${{ needs.release-policy.outputs.warning_required }}" == "true" ]]; then
|
||||||
|
mapfile -t warnings < <(find src-tauri/target/desktop-release-input/macos -name 'UNSIGNED-PLATFORM-RELEASE.txt' -type f)
|
||||||
|
if [[ ${#warnings[@]} -ne 1 ]]; then
|
||||||
|
echo "The unsigned-platform release warning is required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
include_args+=(--include "${warnings[0]}")
|
||||||
|
notes_args+=(--notes "UNSIGNED PLATFORM RELEASE: macOS is ad-hoc signed and not notarized; Windows is not Authenticode-signed. Gatekeeper and SmartScreen warnings are expected. Verify tag, commit, updater signatures, and SHA256SUMS.txt before installation.")
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm run desktop:update-feed:create -- \
|
||||||
|
--artifact "${mac_artifacts[0]}" \
|
||||||
|
--platform-artifact "windows-x86_64=${windows_artifacts[0]}" \
|
||||||
|
"${include_args[@]}" \
|
||||||
|
"${notes_args[@]}" \
|
||||||
|
--output-dir src-tauri/target/desktop-release-feed \
|
||||||
|
--base-url "${DESKTOP_UPDATE_BASE_URL}"
|
||||||
|
|
||||||
|
- name: Verify combined desktop update feed
|
||||||
|
run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}" --require-platform windows-x86_64 --require-provenance
|
||||||
|
|
||||||
|
- name: Upload verified desktop release directory
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ctms-desktop-release-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
||||||
|
path: frontend/src-tauri/target/desktop-release-feed/*
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
name: Desktop Windows Internal Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: desktop-windows-internal-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows-internal:
|
||||||
|
name: Windows NSIS internal validation
|
||||||
|
runs-on: windows-latest
|
||||||
|
env:
|
||||||
|
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.13"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Resolve internal build metadata
|
||||||
|
id: build-metadata
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if ($env:GITHUB_REF_TYPE -eq "tag") {
|
||||||
|
throw "Windows internal validation builds must run from a branch, not a release tag."
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = "dev"
|
||||||
|
if ($env:GITHUB_REF_NAME -in @("dev", "main", "release")) {
|
||||||
|
$channel = $env:GITHUB_REF_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
"channel=$channel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||||
|
"commit=$env:GITHUB_SHA" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Check release build metadata
|
||||||
|
run: npm run release:env:check
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|
||||||
|
- name: Check synchronized client version
|
||||||
|
run: npm run version:check
|
||||||
|
|
||||||
|
- name: Check runtime boundary
|
||||||
|
run: npm run runtime:check
|
||||||
|
|
||||||
|
- name: Check desktop release and security gate
|
||||||
|
run: npm run desktop:release:check
|
||||||
|
|
||||||
|
- name: Check UI contract
|
||||||
|
run: npm run ui:contract
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npm run type-check
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm run test:unit
|
||||||
|
|
||||||
|
- name: Build Web artifact
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|
||||||
|
- name: Write Windows internal Tauri config
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$config = @{
|
||||||
|
bundle = @{
|
||||||
|
createUpdaterArtifacts = $false
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json -Depth 8
|
||||||
|
Set-Content -Path "tauri.windows.internal.conf.json" -Value $config -Encoding utf8
|
||||||
|
Get-Content "tauri.windows.internal.conf.json"
|
||||||
|
|
||||||
|
- name: Build unsigned Windows NSIS internal installer
|
||||||
|
timeout-minutes: 30
|
||||||
|
run: npm run desktop:build -- --config tauri.windows.internal.conf.json --bundles nsis --ci
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|
||||||
|
- name: Create Windows installer checksum manifest
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$bundleDir = "src-tauri/target/release/bundle/nsis"
|
||||||
|
$installers = Get-ChildItem -Path $bundleDir -Filter "*.exe" -File
|
||||||
|
if ($installers.Count -eq 0) {
|
||||||
|
throw "No Windows NSIS EXE installer was produced."
|
||||||
|
}
|
||||||
|
|
||||||
|
$checksums = foreach ($installer in $installers) {
|
||||||
|
$hash = Get-FileHash -Path $installer.FullName -Algorithm SHA256
|
||||||
|
"$($hash.Hash.ToLowerInvariant()) $($installer.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = Join-Path $bundleDir "SHA256SUMS.txt"
|
||||||
|
$checksums | Set-Content -Path $manifest -Encoding utf8
|
||||||
|
Get-Content $manifest
|
||||||
|
|
||||||
|
- name: Upload Windows internal installer
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ctms-windows-internal-${{ steps.build-metadata.outputs.channel }}-${{ steps.build-metadata.outputs.commit }}
|
||||||
|
path: |
|
||||||
|
frontend/src-tauri/target/release/bundle/nsis/*.exe
|
||||||
|
frontend/src-tauri/target/release/bundle/nsis/SHA256SUMS.txt
|
||||||
|
if-no-files-found: error
|
||||||
@@ -53,6 +53,8 @@ pyrightconfig.json
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/.vite/
|
frontend/.vite/
|
||||||
|
frontend/src-tauri/target/
|
||||||
|
frontend/src-tauri/gen/schemas/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
@@ -82,4 +84,5 @@ backend/app/uploads/
|
|||||||
|
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
worktrees/
|
||||||
.install-logs/
|
.install-logs/
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
桌面端项目计划书已完成并移除;当前桌面端执行约束以本文件为准。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知、自动更新和桌面端安全边界。历史设计只作追溯参考,见 `docs/desktop-phase-1-design.md`、`docs/desktop-phase-2-design.md`、`docs/audits/desktop-release-stabilization-checklist.md`。
|
||||||
|
|
||||||
|
桌面端当前不是空白初始化项目。Tauri 基线、macOS 在线桌面壳和第二阶段原生能力主体已经形成;后续工作限于修复、稳定化、体验收口、发布准备、在线辅助缓存、Windows 兼容验证和已批准的 Windows 正式发布。不得重新按第一阶段空白项目初始化 Tauri,不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。
|
||||||
|
|
||||||
|
除非用户明确要求先调整本文件中的约束,否则不要实现离线功能、本地业务权威数据存储、内嵌后端服务、离线同步、本地优先工作流或新的阶段性桌面产品线。在线辅助本地缓存只允许作为已认证在线客户端的体验加速能力;处理桌面本地缓存、请求去重、条件请求、缓存诊断或缓存清理相关任务时,必须阅读并遵守 `docs/desktop-local-cache-plan.md`。
|
||||||
|
|
||||||
|
处理前端或桌面端实现时,优先保持以下边界:
|
||||||
|
|
||||||
|
- 共享业务代码通过 `frontend/src/runtime/index.ts` 获取平台能力。
|
||||||
|
- Tauri API 仅允许出现在 `frontend/src/runtime/`、`frontend/src-tauri/` 或有明确记录的窄入口中。
|
||||||
|
- 本地缓存能力只能通过 `frontend/src/runtime/desktopDataCache.ts`、`frontend/src/runtime/index.ts`、`clientRuntime.dataCache` 和 API 客户端的受控入口暴露;业务模块不得直接使用 Tauri 存储 API、文件系统、SQLite、IndexedDB 或 Cache Storage 实现缓存。
|
||||||
|
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
|
||||||
|
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。
|
||||||
|
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
|
||||||
|
- 正式桌面客户端的默认 CTMS 服务端入口必须由构建环境变量 `VITE_DESKTOP_SERVER_URL` 注入,不得在运行时代码中写死生产域名;用户仍可在桌面服务器设置中手动覆盖,手动值优先并沿用既有的切换服务器登出与缓存失效边界。该变量只表示业务服务端 origin,不得与 updater 制品前缀 `DESKTOP_UPDATE_BASE_URL` 混用。
|
||||||
|
- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建。平台签名默认要求组织 Windows 代码签名证书、RFC 3161 时间戳和 Authenticode 校验;只有 `frontend/desktop-release-policy.json` 中按精确版本记录、经发布负责人批准的例外可跳过平台签名。无论是否采用平台签名例外,都必须使用 updater 私钥、校验 updater feed,并明确标记平台未签名风险。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。
|
||||||
|
- v0.1.0 另获准在 GitHub Actions 额度不可用时,从最终 `release` 上不可移动的 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS ad-hoc 制品;Windows 只能在额度恢复后从同一 tag/SHA 后补。面向安装用户的 GitHub Release 只保留 DMG 与只校验该安装包的 `SHA256SUMS.txt`,平台未签名风险和 Windows pending 状态写入 Release Notes。macOS updater 包及 `.sig`、完整 checksum、provenance、`UNSIGNED-PLATFORM` 与 Windows pending 证据必须保存在被 Git 忽略的私有发布目录,待 Windows `NotSigned` 制品和联合 feed 均验证通过后再发布到可匿名读取的独立 HTTPS updater 源;此前不得发布或替换生产 `latest.json`。该本地/分阶段例外不适用于后续版本。
|
||||||
|
|
||||||
|
## 分支与发布治理
|
||||||
|
|
||||||
|
处理代码提交、分支同步、版本晋级、正式发布或生产热修复前,必须先阅读:
|
||||||
|
|
||||||
|
- `docs/guides/branch-maintenance-sop-zh.md`
|
||||||
|
- `docs/branch-governance.md`
|
||||||
|
- 涉及网页端或桌面端客户端发布时,还需阅读 `docs/guides/client-release.md`
|
||||||
|
|
||||||
|
必须遵守以下规则:
|
||||||
|
|
||||||
|
- CTMS 网页端和桌面端属于同一个产品,共用 `dev`、`main`、`release` 分支,不创建 `web-dev`、`desktop-dev`、`web-release`、`desktop-release` 等长期平行分支。
|
||||||
|
- 默认晋级路径为短期任务分支进入 `dev`,再由 `dev` 晋级到 `main`,最后由 `main` 发布到 `release`。
|
||||||
|
- Agent 创建分支时默认使用 `codex/<任务名称>`;分支必须从最新 `dev` 创建,并在合并到 `dev` 后删除。
|
||||||
|
- `codex/ctms-desktop` 是历史桌面端临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。
|
||||||
|
- 生产热修复从 `release` 创建,合并到 `release` 后必须依次回合并到 `main` 和 `dev`。
|
||||||
|
- 网页端和桌面端必须使用同一个语义化版本号、正式标签和源代码提交。修改客户端版本时使用 `frontend/package.json` 中的 `version:set` 和 `version:check` 命令。
|
||||||
|
- 平台差异必须收敛在 `frontend/src/runtime/` 之后,不能通过长期分支或复制业务代码维护桌面差异。
|
||||||
|
- 未经用户明确要求,不执行提交、推送、合并、变基、打标签、删除分支或强制更新远程分支。
|
||||||
|
- 执行用户明确要求的 Git 操作前,先检查工作区和目标分支,只暂存本次任务相关文件,不覆盖或撤销用户已有改动。
|
||||||
|
- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行任何分支切换、提交、推送或变基前必须先说明目标基线,并等待用户明确指令。
|
||||||
|
- 分支治理规则发生变化时,必须同步更新上述治理文档,不能只修改 `AGENTS.md`。
|
||||||
|
|
||||||
|
## 常用质量门禁
|
||||||
|
|
||||||
|
前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run version:check
|
||||||
|
npm run release:env:check
|
||||||
|
npm run runtime:check
|
||||||
|
npm run desktop:release:check
|
||||||
|
npm run ui:contract
|
||||||
|
npm run type-check
|
||||||
|
npm run test:unit
|
||||||
|
npm run build
|
||||||
|
npm run desktop:build:app
|
||||||
|
```
|
||||||
|
|
||||||
|
本地缓存相关变更至少执行 `runtime:check`、`desktop:release:check`、`ui:contract`、`type-check`、`test:unit` 和 `build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
|
||||||
|
|
||||||
|
正式桌面发布构建必须使用组织批准的 updater 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证、Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、Release Notes、私有发布证据、完整 updater 校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。面向安装用户的 GitHub Release 可按精确版本策略精简为安装包与对应 checksum,但不得因此删除私有 updater 签名制品或验证证据。该例外不自动适用于后续版本。
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
|
|
||||||
## 生产部署
|
## 生产部署
|
||||||
- 生产入口:`docker-compose.yaml`
|
- 生产入口:`docker-compose.yaml`
|
||||||
- 初始化方式:`docker compose run --rm backend-init`
|
- 初始化方式:`docker compose run --rm --build backend-init`
|
||||||
- 启动方式:`docker compose up -d --build`
|
- 启动方式:`docker compose up -d --build`
|
||||||
- 运行拓扑:`nginx`、`backend`、`db`
|
- 运行拓扑:`nginx`、`backend`、`db`、`onlyoffice`
|
||||||
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
|
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
|
||||||
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
|
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
|
||||||
- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
|
- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
- `docker compose config`
|
- `docker compose config`
|
||||||
- `curl -i http://127.0.0.1:8888/`
|
- `curl -i http://127.0.0.1:8888/`
|
||||||
- `curl -i http://127.0.0.1:8888/health`
|
- `curl -i http://127.0.0.1:8888/health`
|
||||||
|
- `curl -i http://127.0.0.1:8888/readyz`
|
||||||
|
|
||||||
## 账号与注册
|
## 账号与注册
|
||||||
- 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建)
|
- 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建)
|
||||||
@@ -38,12 +39,29 @@
|
|||||||
## 访问方式
|
## 访问方式
|
||||||
- 前端:`http://localhost:8888`
|
- 前端:`http://localhost:8888`
|
||||||
- 后端 API:同域 `/api/v1/*`
|
- 后端 API:同域 `/api/v1/*`
|
||||||
- `nginx` 负责托管前端静态资源,并将 `/api` 与 `/health` 转发到 `backend`
|
- `nginx` 负责托管前端静态资源,并将 `/api`、`/health` 与 `/readyz` 转发到 `backend`
|
||||||
|
|
||||||
|
## macOS 桌面端开发
|
||||||
|
- 桌面端约束集中在 `AGENTS.md`:Tauri 在线客户端,不内嵌后端、不保存本地业务权威数据、不做离线同步;在线辅助缓存必须遵守 `docs/desktop-local-cache-plan.md`。
|
||||||
|
- 开发启动:进入 `frontend/` 后执行 `npm run desktop:dev`。
|
||||||
|
- 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`;DMG 构建执行 `npm run desktop:bundle:dmg`。
|
||||||
|
- 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health`。
|
||||||
|
- 生产或非本地服务地址必须使用 HTTPS;本地开发允许 `http://localhost` 或 `http://127.0.0.1`。
|
||||||
|
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `npm run version:check` 检查漂移。
|
||||||
|
- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`。
|
||||||
|
|
||||||
|
## ONLYOFFICE 标准组件
|
||||||
|
|
||||||
|
- `./install.sh` 或 `bash scripts/install.sh dev|main|release` 默认生成独立 JWT、持久化实例标识并启动 Document Server,不再需要额外启用 Compose profile。
|
||||||
|
- `bash scripts/onlyoffice-dev-up.sh` 仅保留为开发环境重建、健康验证和密钥轮换入口;普通安装无需再执行。
|
||||||
|
- 默认镜像、字体、安全配置和生产许可要求见 `docs/guides/onlyoffice-preview.md`。
|
||||||
|
|
||||||
## 仓库治理文档
|
## 仓库治理文档
|
||||||
- 分支治理规范:`docs/branch-governance.md`
|
- 分支治理规范:`docs/branch-governance.md`
|
||||||
|
- 分支维护中文 SOP:`docs/guides/branch-maintenance-sop-zh.md`
|
||||||
- 分支环境安装配置:`docs/guides/branch-environment-installation.md`
|
- 分支环境安装配置:`docs/guides/branch-environment-installation.md`
|
||||||
- 发布检查清单:`docs/guides/release-checklist.md`
|
- 发布检查清单:`docs/guides/release-checklist.md`
|
||||||
|
- 系统监测简易运维:`docs/guides/system-monitoring-operations.md`
|
||||||
|
|
||||||
## 本地配置
|
## 本地配置
|
||||||
- 本地编辑器配置(如 `.vscode/`)不纳入版本库。
|
- 本地编辑器配置(如 `.vscode/`)不纳入版本库。
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""add email verification settings
|
||||||
|
|
||||||
|
Revision ID: 20260629_01
|
||||||
|
Revises: 20260608_01
|
||||||
|
Create Date: 2026-06-29 11:20:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260629_01"
|
||||||
|
down_revision: Union[str, None] = "20260608_01"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_column() -> sa.Column:
|
||||||
|
return sa.Column(
|
||||||
|
"id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
smtp_security = postgresql.ENUM("NONE", "SSL", "STARTTLS", name="smtp_security", create_type=False)
|
||||||
|
email_purpose = postgresql.ENUM("REGISTER", "PASSWORD_RESET", name="email_verification_purpose", create_type=False)
|
||||||
|
smtp_security.create(bind, checkfirst=True)
|
||||||
|
email_purpose.create(bind, checkfirst=True)
|
||||||
|
|
||||||
|
if not _table_exists(inspector, "system_email_settings"):
|
||||||
|
op.create_table(
|
||||||
|
"system_email_settings",
|
||||||
|
_uuid_column(),
|
||||||
|
sa.Column("smtp_host", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("smtp_port", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("smtp_security", smtp_security, server_default="SSL", nullable=False),
|
||||||
|
sa.Column("smtp_username", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("smtp_password_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("sender_email", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("sender_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("allowed_register_domain", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("verification_code_ttl_minutes", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("send_cooldown_seconds", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("max_verify_attempts", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("updated_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by"], ["users.id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists(inspector, "email_verification_codes"):
|
||||||
|
op.create_table(
|
||||||
|
"email_verification_codes",
|
||||||
|
_uuid_column(),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("purpose", email_purpose, server_default="REGISTER", nullable=False),
|
||||||
|
sa.Column("code_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_email_verification_codes_email",
|
||||||
|
"email_verification_codes",
|
||||||
|
["email"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if _table_exists(inspector, "email_verification_codes"):
|
||||||
|
op.drop_index("ix_email_verification_codes_email", table_name="email_verification_codes")
|
||||||
|
op.drop_table("email_verification_codes")
|
||||||
|
if _table_exists(inspector, "system_email_settings"):
|
||||||
|
op.drop_table("system_email_settings")
|
||||||
|
sa.Enum("REGISTER", "PASSWORD_RESET", name="email_verification_purpose").drop(bind, checkfirst=True)
|
||||||
|
sa.Enum("NONE", "SSL", "STARTTLS", name="smtp_security").drop(bind, checkfirst=True)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""default multiple register email domains
|
||||||
|
|
||||||
|
Revision ID: 20260629_02
|
||||||
|
Revises: 20260629_01
|
||||||
|
Create Date: 2026-06-29 12:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260629_02"
|
||||||
|
down_revision: Union[str, None] = "20260629_01"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if "system_email_settings" in inspector.get_table_names():
|
||||||
|
op.execute(
|
||||||
|
"UPDATE system_email_settings "
|
||||||
|
"SET allowed_register_domain = 'huapont.cn,qq.com' "
|
||||||
|
"WHERE allowed_register_domain IN ('huapont.cn', '@huapont.cn')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if "system_email_settings" in inspector.get_table_names():
|
||||||
|
op.execute(
|
||||||
|
"UPDATE system_email_settings "
|
||||||
|
"SET allowed_register_domain = 'huapont.cn' "
|
||||||
|
"WHERE allowed_register_domain = 'huapont.cn,qq.com'"
|
||||||
|
)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""email settings per register domain
|
||||||
|
|
||||||
|
Revision ID: 20260629_03
|
||||||
|
Revises: 20260629_02
|
||||||
|
Create Date: 2026-06-29 14:20:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260629_03"
|
||||||
|
down_revision: Union[str, None] = "20260629_02"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||||
|
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_domains(value: str | None) -> list[str]:
|
||||||
|
domains: list[str] = []
|
||||||
|
for item in (value or "huapont.cn,qq.com").replace(",", ",").split(","):
|
||||||
|
domain = item.strip().lower().lstrip("@")
|
||||||
|
if domain and domain not in domains:
|
||||||
|
domains.append(domain)
|
||||||
|
return domains or ["huapont.cn", "qq.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not _table_exists(inspector, "system_email_settings"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _column_exists(inspector, "system_email_settings", "register_domain"):
|
||||||
|
op.add_column("system_email_settings", sa.Column("register_domain", sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
rows = bind.execute(sa.text("SELECT * FROM system_email_settings ORDER BY created_at ASC")).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
domains = _normalize_domains(row.get("allowed_register_domain"))
|
||||||
|
primary_domain = domains[0]
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE system_email_settings "
|
||||||
|
"SET register_domain = :register_domain, allowed_register_domain = :register_domain "
|
||||||
|
"WHERE id = :id"
|
||||||
|
),
|
||||||
|
{"register_domain": primary_domain, "id": row["id"]},
|
||||||
|
)
|
||||||
|
for domain in domains[1:]:
|
||||||
|
exists = bind.execute(
|
||||||
|
sa.text("SELECT 1 FROM system_email_settings WHERE register_domain = :register_domain"),
|
||||||
|
{"register_domain": domain},
|
||||||
|
).first()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO system_email_settings (
|
||||||
|
id, register_domain, smtp_host, smtp_port, smtp_security, smtp_username,
|
||||||
|
smtp_password_encrypted, sender_email, sender_name, allowed_register_domain,
|
||||||
|
verification_code_ttl_minutes, send_cooldown_seconds, max_verify_attempts,
|
||||||
|
updated_by, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:id, :register_domain, :smtp_host, :smtp_port, :smtp_security, :smtp_username,
|
||||||
|
:smtp_password_encrypted, :sender_email, :sender_name, :allowed_register_domain,
|
||||||
|
:verification_code_ttl_minutes, :send_cooldown_seconds, :max_verify_attempts,
|
||||||
|
:updated_by, now(), now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"register_domain": domain,
|
||||||
|
"smtp_host": row["smtp_host"],
|
||||||
|
"smtp_port": row["smtp_port"],
|
||||||
|
"smtp_security": row["smtp_security"],
|
||||||
|
"smtp_username": row["smtp_username"],
|
||||||
|
"smtp_password_encrypted": row["smtp_password_encrypted"],
|
||||||
|
"sender_email": row["sender_email"],
|
||||||
|
"sender_name": row["sender_name"],
|
||||||
|
"allowed_register_domain": domain,
|
||||||
|
"verification_code_ttl_minutes": row["verification_code_ttl_minutes"],
|
||||||
|
"send_cooldown_seconds": row["send_cooldown_seconds"],
|
||||||
|
"max_verify_attempts": row["max_verify_attempts"],
|
||||||
|
"updated_by": row["updated_by"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
op.alter_column("system_email_settings", "register_domain", existing_type=sa.String(length=255), nullable=False)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_system_email_settings_register_domain",
|
||||||
|
"system_email_settings",
|
||||||
|
["register_domain"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not _table_exists(inspector, "system_email_settings"):
|
||||||
|
return
|
||||||
|
uniques = {item["name"] for item in inspector.get_unique_constraints("system_email_settings")}
|
||||||
|
if "uq_system_email_settings_register_domain" in uniques:
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_system_email_settings_register_domain",
|
||||||
|
"system_email_settings",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
if _column_exists(inspector, "system_email_settings", "register_domain"):
|
||||||
|
op.drop_column("system_email_settings", "register_domain")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add password reset email purpose
|
||||||
|
|
||||||
|
Revision ID: 20260629_04
|
||||||
|
Revises: 20260629_03
|
||||||
|
Create Date: 2026-06-29 16:50:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260629_04"
|
||||||
|
down_revision: Union[str, None] = "20260629_03"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TYPE email_verification_purpose ADD VALUE IF NOT EXISTS 'PASSWORD_RESET'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# PostgreSQL enum values cannot be removed safely without recreating the type.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add password reset link email purpose
|
||||||
|
|
||||||
|
Revision ID: 20260630_01
|
||||||
|
Revises: 20260629_04
|
||||||
|
Create Date: 2026-06-30 10:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260630_01"
|
||||||
|
down_revision: Union[str, None] = "20260629_04"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TYPE email_verification_purpose ADD VALUE IF NOT EXISTS 'PASSWORD_RESET_LINK'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# PostgreSQL enum values cannot be removed safely without recreating the type.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""add desktop phase 2 notification and client metadata state
|
||||||
|
|
||||||
|
Revision ID: 20260630_02
|
||||||
|
Revises: 20260630_01
|
||||||
|
Create Date: 2026-06-30 21:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260630_02"
|
||||||
|
down_revision: Union[str, None] = "20260630_01"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("security_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
|
||||||
|
op.add_column("security_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
|
||||||
|
op.add_column("security_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
|
||||||
|
op.add_column("security_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
|
||||||
|
op.add_column("security_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
|
||||||
|
op.create_index(
|
||||||
|
"ix_security_log_client_created",
|
||||||
|
"security_access_logs",
|
||||||
|
["client_type", "client_version", "created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"desktop_notification_subscriptions",
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||||
|
sa.Column("enabled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("user_id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"desktop_notification_deliveries",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("distribution_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("claim_token", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["distribution_id"], ["distributions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"user_id",
|
||||||
|
"distribution_id",
|
||||||
|
name="uq_desktop_notification_user_distribution",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_desktop_notification_claim",
|
||||||
|
"desktop_notification_deliveries",
|
||||||
|
["user_id", "delivered_at", "claimed_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_desktop_notification_claim", table_name="desktop_notification_deliveries")
|
||||||
|
op.drop_table("desktop_notification_deliveries")
|
||||||
|
op.drop_table("desktop_notification_subscriptions")
|
||||||
|
op.drop_index("ix_security_log_client_created", table_name="security_access_logs")
|
||||||
|
for column in ("build_commit", "build_channel", "client_platform", "client_version", "client_type"):
|
||||||
|
op.drop_column("security_access_logs", column)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""add access context to audit logs
|
||||||
|
|
||||||
|
Revision ID: 20260709_01
|
||||||
|
Revises: 20260630_02
|
||||||
|
Create Date: 2026-07-09 10:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260709_01"
|
||||||
|
down_revision: Union[str, None] = "20260630_02"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("audit_logs", sa.Column("client_ip", sa.String(45), nullable=True))
|
||||||
|
op.add_column("audit_logs", sa.Column("user_agent", sa.String(500), nullable=True))
|
||||||
|
op.add_column("audit_logs", sa.Column("client_type", sa.String(16), nullable=True))
|
||||||
|
op.add_column("audit_logs", sa.Column("client_version", sa.String(32), nullable=True))
|
||||||
|
op.add_column("audit_logs", sa.Column("client_platform", sa.String(16), nullable=True))
|
||||||
|
op.add_column("audit_logs", sa.Column("build_channel", sa.String(16), nullable=True))
|
||||||
|
op.add_column("audit_logs", sa.Column("build_commit", sa.String(64), nullable=True))
|
||||||
|
op.create_index("ix_audit_logs_operator_created", "audit_logs", ["operator_id", "created_at"])
|
||||||
|
op.create_index("ix_audit_logs_client_ip_created", "audit_logs", ["client_ip", "created_at"])
|
||||||
|
op.create_index("ix_audit_logs_client_source_created", "audit_logs", ["client_type", "created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_audit_logs_client_source_created", table_name="audit_logs")
|
||||||
|
op.drop_index("ix_audit_logs_client_ip_created", table_name="audit_logs")
|
||||||
|
op.drop_index("ix_audit_logs_operator_created", table_name="audit_logs")
|
||||||
|
for column in (
|
||||||
|
"build_commit",
|
||||||
|
"build_channel",
|
||||||
|
"client_platform",
|
||||||
|
"client_version",
|
||||||
|
"client_type",
|
||||||
|
"user_agent",
|
||||||
|
"client_ip",
|
||||||
|
):
|
||||||
|
op.drop_column("audit_logs", column)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""add access context to permission access logs
|
||||||
|
|
||||||
|
Revision ID: 20260709_02
|
||||||
|
Revises: 20260709_01
|
||||||
|
Create Date: 2026-07-09 15:45:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260709_02"
|
||||||
|
down_revision: Union[str, None] = "20260709_01"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("permission_access_logs", sa.Column("user_agent", sa.String(500), nullable=True))
|
||||||
|
op.add_column("permission_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
|
||||||
|
op.add_column("permission_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
|
||||||
|
op.add_column("permission_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
|
||||||
|
op.add_column("permission_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
|
||||||
|
op.add_column("permission_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
|
||||||
|
op.create_index("ix_perm_log_ip_created", "permission_access_logs", ["ip_address", "created_at"])
|
||||||
|
op.create_index("ix_perm_log_client_source_created", "permission_access_logs", ["client_type", "created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_perm_log_client_source_created", table_name="permission_access_logs")
|
||||||
|
op.drop_index("ix_perm_log_ip_created", table_name="permission_access_logs")
|
||||||
|
for column in (
|
||||||
|
"build_commit",
|
||||||
|
"build_channel",
|
||||||
|
"client_platform",
|
||||||
|
"client_version",
|
||||||
|
"client_type",
|
||||||
|
"user_agent",
|
||||||
|
):
|
||||||
|
op.drop_column("permission_access_logs", column)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add request headers to permission access logs
|
||||||
|
|
||||||
|
Revision ID: 20260709_03
|
||||||
|
Revises: 20260709_02
|
||||||
|
Create Date: 2026-07-09 16:58:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260709_03"
|
||||||
|
down_revision: Union[str, None] = "20260709_02"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
REQUEST_HEADERS_TYPE = sa.JSON().with_variant(
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
"postgresql",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"permission_access_logs",
|
||||||
|
sa.Column("request_headers", REQUEST_HEADERS_TYPE, nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("permission_access_logs", "request_headers")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add request headers to security access logs
|
||||||
|
|
||||||
|
Revision ID: 20260709_04
|
||||||
|
Revises: 20260709_03
|
||||||
|
Create Date: 2026-07-09 17:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260709_04"
|
||||||
|
down_revision: Union[str, None] = "20260709_03"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
REQUEST_HEADERS_TYPE = sa.JSON().with_variant(
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
"postgresql",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"security_access_logs",
|
||||||
|
sa.Column("request_headers", REQUEST_HEADERS_TYPE, nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("security_access_logs", "request_headers")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""add request snapshots to access logs
|
||||||
|
|
||||||
|
Revision ID: 20260709_05
|
||||||
|
Revises: 20260709_04
|
||||||
|
Create Date: 2026-07-09 18:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260709_05"
|
||||||
|
down_revision: Union[str, None] = "20260709_04"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
REQUEST_SNAPSHOT_TYPE = sa.JSON().with_variant(
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
"postgresql",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"permission_access_logs",
|
||||||
|
sa.Column("request_snapshot", REQUEST_SNAPSHOT_TYPE, nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"security_access_logs",
|
||||||
|
sa.Column("request_snapshot", REQUEST_SNAPSHOT_TYPE, nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("security_access_logs", "request_snapshot")
|
||||||
|
op.drop_column("permission_access_logs", "request_snapshot")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""add monitoring event identity and persisted security classification
|
||||||
|
|
||||||
|
Revision ID: 20260710_01
|
||||||
|
Revises: 20260709_05
|
||||||
|
Create Date: 2026-07-10 09:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260710_01"
|
||||||
|
down_revision: Union[str, None] = "20260709_05"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("permission_access_logs", sa.Column("request_id", sa.String(36), nullable=True))
|
||||||
|
op.add_column("security_access_logs", sa.Column("request_id", sa.String(36), nullable=True))
|
||||||
|
op.add_column("security_access_logs", sa.Column("category", sa.String(30), nullable=True))
|
||||||
|
op.add_column("security_access_logs", sa.Column("severity", sa.String(16), nullable=True))
|
||||||
|
|
||||||
|
op.create_index("ix_perm_log_request_id", "permission_access_logs", ["request_id"])
|
||||||
|
op.create_index("ix_security_log_request_id", "security_access_logs", ["request_id"])
|
||||||
|
op.create_index("ix_security_log_category_created", "security_access_logs", ["category", "created_at"])
|
||||||
|
op.create_index("ix_security_log_severity_created", "security_access_logs", ["severity", "created_at"])
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE security_access_logs
|
||||||
|
SET category = CASE
|
||||||
|
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
|
||||||
|
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
|
||||||
|
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
|
||||||
|
THEN 'PROBE'
|
||||||
|
WHEN status_code >= 500 THEN 'SERVER_ERROR'
|
||||||
|
WHEN auth_status = 'INVALID_TOKEN' THEN 'INVALID_TOKEN'
|
||||||
|
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'ANONYMOUS_API'
|
||||||
|
WHEN status_code = 404 THEN 'NOT_FOUND_NOISE'
|
||||||
|
ELSE 'OTHER'
|
||||||
|
END,
|
||||||
|
severity = CASE
|
||||||
|
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
|
||||||
|
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
|
||||||
|
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
|
||||||
|
THEN 'CRITICAL'
|
||||||
|
WHEN status_code >= 500 THEN 'HIGH'
|
||||||
|
WHEN auth_status = 'INVALID_TOKEN' THEN 'MEDIUM'
|
||||||
|
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'MEDIUM'
|
||||||
|
ELSE 'LOW'
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_security_log_severity_created", table_name="security_access_logs")
|
||||||
|
op.drop_index("ix_security_log_category_created", table_name="security_access_logs")
|
||||||
|
op.drop_index("ix_security_log_request_id", table_name="security_access_logs")
|
||||||
|
op.drop_index("ix_perm_log_request_id", table_name="permission_access_logs")
|
||||||
|
op.drop_column("security_access_logs", "severity")
|
||||||
|
op.drop_column("security_access_logs", "category")
|
||||||
|
op.drop_column("security_access_logs", "request_id")
|
||||||
|
op.drop_column("permission_access_logs", "request_id")
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""add source location hourly snapshots
|
||||||
|
|
||||||
|
Revision ID: 20260710_02
|
||||||
|
Revises: 20260710_01
|
||||||
|
Create Date: 2026-07-10 15:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260710_02"
|
||||||
|
down_revision: Union[str, None] = "20260710_01"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"source_location_snapshots",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("ip_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("user_hash", sa.String(length=64), nullable=False, server_default=""),
|
||||||
|
sa.Column("country", sa.String(length=100), nullable=False, server_default=""),
|
||||||
|
sa.Column("country_code", sa.String(length=16), nullable=False, server_default=""),
|
||||||
|
sa.Column("province", sa.String(length=100), nullable=False, server_default=""),
|
||||||
|
sa.Column("region_code", sa.String(length=24), nullable=False, server_default=""),
|
||||||
|
sa.Column("city", sa.String(length=100), nullable=False, server_default=""),
|
||||||
|
sa.Column("isp", sa.String(length=160), nullable=False, server_default=""),
|
||||||
|
sa.Column("location", sa.String(length=320), nullable=False, server_default=""),
|
||||||
|
sa.Column("longitude", sa.Float(), nullable=True),
|
||||||
|
sa.Column("latitude", sa.Float(), nullable=True),
|
||||||
|
sa.Column("accuracy_level", sa.String(length=16), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("allowed_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("denied_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("security_event_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("high_risk_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("auth_failure_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("bucket_time", "ip_hash", "user_hash", name="uq_source_location_bucket_identity"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_source_location_bucket", "source_location_snapshots", ["bucket_time"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_source_location_country_bucket",
|
||||||
|
"source_location_snapshots",
|
||||||
|
["country_code", "bucket_time"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_source_location_risk_bucket",
|
||||||
|
"source_location_snapshots",
|
||||||
|
["high_risk_count", "bucket_time"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE security_access_logs
|
||||||
|
SET category = CASE
|
||||||
|
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
|
||||||
|
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
|
||||||
|
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
|
||||||
|
THEN 'PROBE'
|
||||||
|
WHEN status_code >= 500 THEN 'SERVER_ERROR'
|
||||||
|
WHEN auth_status = 'INVALID_TOKEN' THEN 'INVALID_TOKEN'
|
||||||
|
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'ANONYMOUS_API'
|
||||||
|
WHEN status_code = 404 THEN 'NOT_FOUND_NOISE'
|
||||||
|
ELSE 'OTHER'
|
||||||
|
END,
|
||||||
|
severity = CASE
|
||||||
|
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
|
||||||
|
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
|
||||||
|
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
|
||||||
|
THEN 'CRITICAL'
|
||||||
|
WHEN status_code >= 500 THEN 'HIGH'
|
||||||
|
WHEN auth_status = 'INVALID_TOKEN' THEN 'MEDIUM'
|
||||||
|
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'MEDIUM'
|
||||||
|
ELSE 'LOW'
|
||||||
|
END
|
||||||
|
WHERE category = 'ABNORMAL_IP'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_source_location_risk_bucket", table_name="source_location_snapshots")
|
||||||
|
op.drop_index("ix_source_location_country_bucket", table_name="source_location_snapshots")
|
||||||
|
op.drop_index("ix_source_location_bucket", table_name="source_location_snapshots")
|
||||||
|
op.drop_table("source_location_snapshots")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Remove deployment-specific coordinates from private source snapshots.
|
||||||
|
|
||||||
|
Revision ID: 20260710_03
|
||||||
|
Revises: 20260710_02
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260710_03"
|
||||||
|
down_revision = "20260710_02"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE source_location_snapshots
|
||||||
|
SET longitude = NULL,
|
||||||
|
latitude = NULL
|
||||||
|
WHERE accuracy_level = 'private'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# The previous coordinates were a hardcoded deployment assumption and
|
||||||
|
# cannot be restored without reintroducing incorrect data.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Add user login session activity records.
|
||||||
|
|
||||||
|
Revision ID: 20260710_04
|
||||||
|
Revises: 20260710_03
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260710_04"
|
||||||
|
down_revision = "20260710_03"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user_login_sessions",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("client_type", sa.String(length=16), nullable=False, server_default="web"),
|
||||||
|
sa.Column("client_platform", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("client_version", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("client_source", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("login_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("end_reason", sa.String(length=32), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_user_login_sessions_user_login", "user_login_sessions", ["user_id", "login_at"])
|
||||||
|
op.create_index("ix_user_login_sessions_last_seen", "user_login_sessions", ["last_seen_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_user_login_sessions_last_seen", table_name="user_login_sessions")
|
||||||
|
op.drop_index("ix_user_login_sessions_user_login", table_name="user_login_sessions")
|
||||||
|
op.drop_table("user_login_sessions")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Add the server-observed login IP to user login sessions.
|
||||||
|
|
||||||
|
Revision ID: 20260713_01
|
||||||
|
Revises: 20260710_04
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260713_01"
|
||||||
|
down_revision = "20260710_04"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"user_login_sessions",
|
||||||
|
sa.Column("login_ip", sa.String(length=45), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("user_login_sessions", "login_ip")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Store original filenames for document versions.
|
||||||
|
|
||||||
|
Revision ID: 20260713_02
|
||||||
|
Revises: 20260713_01
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260713_02"
|
||||||
|
down_revision = "20260713_01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"document_versions",
|
||||||
|
sa.Column("original_filename", sa.String(length=255), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("document_versions", "original_filename")
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Add the independent shared-library collaboration module.
|
||||||
|
|
||||||
|
Revision ID: 20260714_01
|
||||||
|
Revises: 20260713_02
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260714_01"
|
||||||
|
down_revision = "20260713_02"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
uuid_type = postgresql.UUID(as_uuid=True)
|
||||||
|
op.create_table(
|
||||||
|
"collaboration_folders",
|
||||||
|
sa.Column("id", uuid_type, primary_key=True),
|
||||||
|
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
|
||||||
|
sa.Column("parent_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("name", sa.String(120), nullable=False),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_collaboration_folders_study_parent", "collaboration_folders", ["study_id", "parent_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"collaboration_files",
|
||||||
|
sa.Column("id", uuid_type, primary_key=True),
|
||||||
|
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
|
||||||
|
sa.Column("folder_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("file_type", sa.String(16), nullable=False),
|
||||||
|
sa.Column("extension", sa.String(16), nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
|
||||||
|
sa.Column("owner_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("current_revision_id", uuid_type),
|
||||||
|
sa.Column("generation", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_collaboration_files_study_folder", "collaboration_files", ["study_id", "folder_id"])
|
||||||
|
op.create_index("ix_collaboration_files_study_status", "collaboration_files", ["study_id", "status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"collaboration_members",
|
||||||
|
sa.Column("id", uuid_type, primary_key=True),
|
||||||
|
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("role", sa.String(16), nullable=False),
|
||||||
|
sa.Column("invited_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_collaboration_members_user", "collaboration_members", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"collaboration_revisions",
|
||||||
|
sa.Column("id", uuid_type, primary_key=True),
|
||||||
|
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("revision_no", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("parent_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("file_uri", sa.String(500), nullable=False),
|
||||||
|
sa.Column("original_filename", sa.String(255), nullable=False),
|
||||||
|
sa.Column("file_hash", sa.String(128), nullable=False),
|
||||||
|
sa.Column("file_size", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("mime_type", sa.String(100), nullable=False),
|
||||||
|
sa.Column("source", sa.String(24), nullable=False),
|
||||||
|
sa.Column("change_summary", sa.Text()),
|
||||||
|
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_collaboration_revisions_file_created", "collaboration_revisions", ["file_id", "created_at"])
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_collaboration_files_current_revision",
|
||||||
|
"collaboration_files",
|
||||||
|
"collaboration_revisions",
|
||||||
|
["current_revision_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"collaboration_sessions",
|
||||||
|
sa.Column("id", uuid_type, primary_key=True),
|
||||||
|
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("base_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id"), nullable=False),
|
||||||
|
sa.Column("document_key", sa.String(128), nullable=False),
|
||||||
|
sa.Column("generation", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
|
||||||
|
sa.Column("started_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("active_users", sa.Text()),
|
||||||
|
sa.Column("last_callback_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("closed_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("document_key", name="uq_collaboration_session_document_key"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_collaboration_sessions_file_status", "collaboration_sessions", ["file_id", "status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"collaboration_callback_receipts",
|
||||||
|
sa.Column("id", uuid_type, primary_key=True),
|
||||||
|
sa.Column("session_id", uuid_type, sa.ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("fingerprint", sa.String(128), nullable=False),
|
||||||
|
sa.Column("callback_status", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("result", sa.String(24), nullable=False),
|
||||||
|
sa.Column("revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("collaboration_callback_receipts")
|
||||||
|
op.drop_index("ix_collaboration_sessions_file_status", table_name="collaboration_sessions")
|
||||||
|
op.drop_table("collaboration_sessions")
|
||||||
|
op.drop_constraint("fk_collaboration_files_current_revision", "collaboration_files", type_="foreignkey")
|
||||||
|
op.drop_index("ix_collaboration_revisions_file_created", table_name="collaboration_revisions")
|
||||||
|
op.drop_table("collaboration_revisions")
|
||||||
|
op.drop_index("ix_collaboration_members_user", table_name="collaboration_members")
|
||||||
|
op.drop_table("collaboration_members")
|
||||||
|
op.drop_index("ix_collaboration_files_study_status", table_name="collaboration_files")
|
||||||
|
op.drop_index("ix_collaboration_files_study_folder", table_name="collaboration_files")
|
||||||
|
op.drop_table("collaboration_files")
|
||||||
|
op.drop_index("ix_collaboration_folders_study_parent", table_name="collaboration_folders")
|
||||||
|
op.drop_table("collaboration_folders")
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Add protected public links for collaboration files.
|
||||||
|
|
||||||
|
Revision ID: 20260715_01
|
||||||
|
Revises: 20260714_01
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260715_01"
|
||||||
|
down_revision = "20260714_01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
uuid_type = postgresql.UUID(as_uuid=True)
|
||||||
|
op.create_table(
|
||||||
|
"collaboration_share_links",
|
||||||
|
sa.Column("id", uuid_type, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"file_id",
|
||||||
|
uuid_type,
|
||||||
|
sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("access_mode", sa.String(12), nullable=False, server_default="VIEW"),
|
||||||
|
sa.Column("expiry_policy", sa.String(16), nullable=False, server_default="SEVEN_DAYS"),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("password_hash", sa.String(255)),
|
||||||
|
sa.Column("token_version", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("failed_attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_failed_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("locked_until", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("updated_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("file_id", name="uq_collaboration_share_link_file"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_collaboration_share_links_enabled_expiry",
|
||||||
|
"collaboration_share_links",
|
||||||
|
["enabled", "expires_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_collaboration_share_links_enabled_expiry", table_name="collaboration_share_links")
|
||||||
|
op.drop_table("collaboration_share_links")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Add effective export controls to collaboration share links.
|
||||||
|
|
||||||
|
Revision ID: 20260715_02
|
||||||
|
Revises: 20260715_01
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260715_02"
|
||||||
|
down_revision = "20260715_01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"collaboration_share_links",
|
||||||
|
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("collaboration_share_links", "allow_export")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Add soft deletion metadata to collaboration revisions.
|
||||||
|
|
||||||
|
Revision ID: 20260715_03
|
||||||
|
Revises: 20260715_02
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260715_03"
|
||||||
|
down_revision = "20260715_02"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"collaboration_revisions",
|
||||||
|
sa.Column("deleted_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"collaboration_revisions",
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_collaboration_revision_deleted_by",
|
||||||
|
"collaboration_revisions",
|
||||||
|
"users",
|
||||||
|
["deleted_by"],
|
||||||
|
["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_collaboration_revision_deleted_by",
|
||||||
|
"collaboration_revisions",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("collaboration_revisions", "deleted_at")
|
||||||
|
op.drop_column("collaboration_revisions", "deleted_by")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Move collaboration export control to the file.
|
||||||
|
|
||||||
|
Revision ID: 20260715_04
|
||||||
|
Revises: 20260715_03
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260715_04"
|
||||||
|
down_revision = "20260715_03"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"collaboration_files",
|
||||||
|
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
|
# Preserve links that already granted export: after consolidation the same
|
||||||
|
# setting applies to authenticated collaborators and anonymous visitors.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE collaboration_files AS file
|
||||||
|
SET allow_export = true
|
||||||
|
FROM collaboration_share_links AS link
|
||||||
|
WHERE link.file_id = file.id AND link.allow_export = true
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("collaboration_files", "allow_export")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Remove the redundant share-link export permission.
|
||||||
|
|
||||||
|
Revision ID: 20260715_05
|
||||||
|
Revises: 20260715_04
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260715_05"
|
||||||
|
down_revision = "20260715_04"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("collaboration_share_links", "allow_export")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"collaboration_share_links",
|
||||||
|
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE collaboration_share_links AS link
|
||||||
|
SET allow_export = file.allow_export
|
||||||
|
FROM collaboration_files AS file
|
||||||
|
WHERE file.id = link.file_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Add collaboration edit requests and ownership controls.
|
||||||
|
|
||||||
|
Revision ID: 20260715_06
|
||||||
|
Revises: 20260715_05
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260715_06"
|
||||||
|
down_revision = "20260715_05"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"collaboration_files",
|
||||||
|
sa.Column("allow_edit_request", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"collaboration_files",
|
||||||
|
sa.Column("allow_sheet_structure_edit", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"collaboration_files",
|
||||||
|
sa.Column("sheet_structure_protection_backup", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"collaboration_edit_requests",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("requester_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="PENDING"),
|
||||||
|
sa.Column("resolved_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["file_id"], ["collaboration_files.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["requester_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_collaboration_edit_requests_file_status",
|
||||||
|
"collaboration_edit_requests",
|
||||||
|
["file_id", "status"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_collaboration_edit_requests_pending_user",
|
||||||
|
"collaboration_edit_requests",
|
||||||
|
["file_id", "requester_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("status = 'PENDING'"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_collaboration_edit_requests_pending_user", table_name="collaboration_edit_requests")
|
||||||
|
op.drop_index("ix_collaboration_edit_requests_file_status", table_name="collaboration_edit_requests")
|
||||||
|
op.drop_table("collaboration_edit_requests")
|
||||||
|
op.drop_column("collaboration_files", "sheet_structure_protection_backup")
|
||||||
|
op.drop_column("collaboration_files", "allow_sheet_structure_edit")
|
||||||
|
op.drop_column("collaboration_files", "allow_edit_request")
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Add recipient-scoped generic notifications.
|
||||||
|
|
||||||
|
Revision ID: 20260716_01
|
||||||
|
Revises: 20260715_06
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260716_01"
|
||||||
|
down_revision = "20260715_06"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"notifications",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("recipient_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("category", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("priority", sa.String(length=16), nullable=False, server_default="NORMAL"),
|
||||||
|
sa.Column("title", sa.String(length=180), nullable=False),
|
||||||
|
sa.Column("message", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("action_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_type", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("source_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("source_version", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("dedupe_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["recipient_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["study_id"], ["studies.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_notifications_recipient_study_state",
|
||||||
|
"notifications",
|
||||||
|
["recipient_id", "study_id", "resolved_at", "read_at", "created_at"],
|
||||||
|
)
|
||||||
|
op.create_index("ix_notifications_source", "notifications", ["source_type", "source_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_notifications_source", table_name="notifications")
|
||||||
|
op.drop_index("ix_notifications_recipient_study_state", table_name="notifications")
|
||||||
|
op.drop_table("notifications")
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Backfill notifications for pending collaboration edit requests.
|
||||||
|
|
||||||
|
Revision ID: 20260716_02
|
||||||
|
Revises: 20260716_01
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260716_02"
|
||||||
|
down_revision = "20260716_01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO notifications (
|
||||||
|
id,
|
||||||
|
study_id,
|
||||||
|
recipient_id,
|
||||||
|
category,
|
||||||
|
priority,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
action_path,
|
||||||
|
source_type,
|
||||||
|
source_id,
|
||||||
|
dedupe_key,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
collaboration_files.study_id,
|
||||||
|
study_members.user_id,
|
||||||
|
'COLLABORATION_EDIT_REQUEST',
|
||||||
|
'NORMAL',
|
||||||
|
'新的编辑权限申请',
|
||||||
|
concat(
|
||||||
|
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
|
||||||
|
' 申请编辑“',
|
||||||
|
collaboration_files.title,
|
||||||
|
'”'
|
||||||
|
),
|
||||||
|
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
|
||||||
|
'COLLABORATION_EDIT_REQUEST',
|
||||||
|
collaboration_edit_requests.id::text,
|
||||||
|
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
|
||||||
|
collaboration_edit_requests.created_at,
|
||||||
|
collaboration_edit_requests.updated_at
|
||||||
|
FROM collaboration_edit_requests
|
||||||
|
JOIN collaboration_files
|
||||||
|
ON collaboration_files.id = collaboration_edit_requests.file_id
|
||||||
|
JOIN users
|
||||||
|
ON users.id = collaboration_edit_requests.requester_id
|
||||||
|
JOIN study_members
|
||||||
|
ON study_members.study_id = collaboration_files.study_id
|
||||||
|
AND study_members.is_active IS TRUE
|
||||||
|
LEFT JOIN collaboration_members
|
||||||
|
ON collaboration_members.file_id = collaboration_files.id
|
||||||
|
AND collaboration_members.user_id = study_members.user_id
|
||||||
|
WHERE collaboration_edit_requests.status = 'PENDING'
|
||||||
|
AND collaboration_files.deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
study_members.user_id = collaboration_files.owner_id
|
||||||
|
OR collaboration_members.role = 'MANAGER'
|
||||||
|
)
|
||||||
|
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 这是业务通知数据迁移;降级时保留已读/未读状态,避免删除升级后新产生的同源通知。
|
||||||
|
pass
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Backfill pending edit request notifications for file owners.
|
||||||
|
|
||||||
|
Revision ID: 20260716_03
|
||||||
|
Revises: 20260716_02
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260716_03"
|
||||||
|
down_revision = "20260716_02"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO notifications (
|
||||||
|
id,
|
||||||
|
study_id,
|
||||||
|
recipient_id,
|
||||||
|
category,
|
||||||
|
priority,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
action_path,
|
||||||
|
source_type,
|
||||||
|
source_id,
|
||||||
|
dedupe_key,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
collaboration_files.study_id,
|
||||||
|
collaboration_files.owner_id,
|
||||||
|
'COLLABORATION_EDIT_REQUEST',
|
||||||
|
'NORMAL',
|
||||||
|
'新的编辑权限申请',
|
||||||
|
concat(
|
||||||
|
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
|
||||||
|
' 申请编辑“',
|
||||||
|
collaboration_files.title,
|
||||||
|
'”'
|
||||||
|
),
|
||||||
|
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
|
||||||
|
'COLLABORATION_EDIT_REQUEST',
|
||||||
|
collaboration_edit_requests.id::text,
|
||||||
|
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
|
||||||
|
collaboration_edit_requests.created_at,
|
||||||
|
collaboration_edit_requests.updated_at
|
||||||
|
FROM collaboration_edit_requests
|
||||||
|
JOIN collaboration_files
|
||||||
|
ON collaboration_files.id = collaboration_edit_requests.file_id
|
||||||
|
JOIN users
|
||||||
|
ON users.id = collaboration_edit_requests.requester_id
|
||||||
|
WHERE collaboration_edit_requests.status = 'PENDING'
|
||||||
|
AND collaboration_files.deleted_at IS NULL
|
||||||
|
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 与上一数据迁移一致,降级时保留已产生的业务通知状态。
|
||||||
|
pass
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Remove shared-library and passive Office preview audit noise.
|
||||||
|
|
||||||
|
Revision ID: 20260716_04
|
||||||
|
Revises: 20260716_03
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260716_04"
|
||||||
|
down_revision = "20260716_03"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
DELETE FROM audit_logs
|
||||||
|
WHERE action = 'OFFICE_PREVIEW_OPEN'
|
||||||
|
OR lower(entity_type) IN (
|
||||||
|
'faq_category',
|
||||||
|
'faq_item',
|
||||||
|
'faq_reply',
|
||||||
|
'faq_replies',
|
||||||
|
'precaution',
|
||||||
|
'knowledge_note',
|
||||||
|
'knowledge_notes',
|
||||||
|
'collaboration_file'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 被清理的是明确排除出审计范围的共享库和被动预览记录,无法也不应伪造恢复。
|
||||||
|
pass
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Expand generic notifications and route desktop delivery through them.
|
||||||
|
|
||||||
|
Revision ID: 20260716_05
|
||||||
|
Revises: 20260716_04
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260716_05"
|
||||||
|
down_revision = "20260716_04"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"notifications",
|
||||||
|
sa.Column("requires_action", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"notifications",
|
||||||
|
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=True)
|
||||||
|
op.add_column(
|
||||||
|
"desktop_notification_deliveries",
|
||||||
|
sa.Column("notification_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_desktop_notification_deliveries_notification",
|
||||||
|
"desktop_notification_deliveries",
|
||||||
|
"notifications",
|
||||||
|
["notification_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_desktop_notification_user_notification",
|
||||||
|
"desktop_notification_deliveries",
|
||||||
|
["user_id", "notification_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_desktop_notification_user_notification",
|
||||||
|
"desktop_notification_deliveries",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_desktop_notification_deliveries_notification",
|
||||||
|
"desktop_notification_deliveries",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("desktop_notification_deliveries", "notification_id")
|
||||||
|
# Legacy rows always have a distribution id; rows created by the generic delivery path do not.
|
||||||
|
op.execute("DELETE FROM desktop_notification_deliveries WHERE distribution_id IS NULL")
|
||||||
|
op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=False)
|
||||||
|
op.drop_column("notifications", "due_at")
|
||||||
|
op.drop_column("notifications", "requires_action")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db_session, require_roles
|
||||||
|
from app.schemas.email_settings import (
|
||||||
|
EmailDomainCreateRequest,
|
||||||
|
EmailSettingsListResponse,
|
||||||
|
EmailSettingsRead,
|
||||||
|
EmailSettingsUpdate,
|
||||||
|
EmailTestRequest,
|
||||||
|
)
|
||||||
|
from app.services import email_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/email-settings")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=EmailSettingsListResponse)
|
||||||
|
async def read_email_settings(
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
|
) -> EmailSettingsListResponse:
|
||||||
|
items = [email_service.to_email_settings_read(row) for row in await email_service.list_email_settings(db)]
|
||||||
|
return EmailSettingsListResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=EmailSettingsRead)
|
||||||
|
async def create_email_domain(
|
||||||
|
payload: EmailDomainCreateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
|
) -> EmailSettingsRead:
|
||||||
|
settings_row = await email_service.create_email_domain(db, payload.register_domain, updated_by=current_user.id)
|
||||||
|
return email_service.to_email_settings_read(settings_row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{register_domain}", response_model=EmailSettingsRead)
|
||||||
|
async def read_email_domain_settings(
|
||||||
|
register_domain: str,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
|
) -> EmailSettingsRead:
|
||||||
|
return email_service.to_email_settings_read(await email_service.get_email_settings(db, register_domain))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{register_domain}", response_model=EmailSettingsRead)
|
||||||
|
async def update_email_settings(
|
||||||
|
register_domain: str,
|
||||||
|
payload: EmailSettingsUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
|
) -> EmailSettingsRead:
|
||||||
|
settings_row = await email_service.upsert_email_settings(db, register_domain, payload, updated_by=current_user.id)
|
||||||
|
return email_service.to_email_settings_read(settings_row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{register_domain}")
|
||||||
|
async def delete_email_domain(
|
||||||
|
register_domain: str,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
|
):
|
||||||
|
await email_service.delete_email_domain(db, register_domain)
|
||||||
|
return {"message": "邮箱后缀已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{register_domain}/test")
|
||||||
|
async def test_email_settings(
|
||||||
|
register_domain: str,
|
||||||
|
payload: EmailTestRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
|
):
|
||||||
|
await email_service.send_test_email(db, register_domain, str(payload.recipient_email))
|
||||||
|
return {"message": "测试邮件已发送"}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_db_session, is_system_admin, require_roles
|
|
||||||
from app.crud import user as user_crud
|
|
||||||
from app.models.user import User, UserStatus
|
|
||||||
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/users")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=AdminUserListResponse)
|
|
||||||
async def list_users_for_review(
|
|
||||||
status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"),
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(require_roles(["ADMIN"])),
|
|
||||||
) -> AdminUserListResponse:
|
|
||||||
users = await user_crud.list_users_by_status(db, status=status_filter)
|
|
||||||
return AdminUserListResponse(items=list(users), total=len(users))
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
user = await user_crud.get_by_id(db, user_id)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
|
||||||
if is_system_admin(user):
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/approve", response_model=UserResponse)
|
|
||||||
async def approve_user(
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
review: UserAdminReviewRequest,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(require_roles(["ADMIN"])),
|
|
||||||
) -> UserResponse:
|
|
||||||
if review.action != "approve":
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
|
|
||||||
user = await _get_review_user(db, user_id)
|
|
||||||
if user.status != UserStatus.PENDING:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
|
|
||||||
user = await user_crud.approve_user(db, user, admin_id=current_user.id)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/reject", response_model=UserResponse)
|
|
||||||
async def reject_user(
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
review: UserAdminReviewRequest,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(require_roles(["ADMIN"])),
|
|
||||||
) -> UserResponse:
|
|
||||||
if review.action not in ("reject", "approve"):
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
|
|
||||||
user = await _get_review_user(db, user_id)
|
|
||||||
if user.status != UserStatus.PENDING:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
|
|
||||||
user = await user_crud.reject_user(db, user, admin_id=current_user.id)
|
|
||||||
return user
|
|
||||||
@@ -77,6 +77,7 @@ FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
|
|||||||
"read": "faq:read",
|
"read": "faq:read",
|
||||||
"delete": "faq_attachments:delete",
|
"delete": "faq_attachments:delete",
|
||||||
}
|
}
|
||||||
|
SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}
|
||||||
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
|
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
|
||||||
"startup_kickoff",
|
"startup_kickoff",
|
||||||
"startup_kickoff_minutes",
|
"startup_kickoff_minutes",
|
||||||
@@ -239,6 +240,7 @@ async def upload_attachment(
|
|||||||
content_type=file.content_type,
|
content_type=file.content_type,
|
||||||
uploaded_by=current_user.id,
|
uploaded_by=current_user.id,
|
||||||
)
|
)
|
||||||
|
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -346,13 +348,10 @@ async def preview_attachment(
|
|||||||
|
|
||||||
|
|
||||||
async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID):
|
async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID):
|
||||||
token = None
|
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
if auth_header and auth_header.lower().startswith("bearer "):
|
if auth_header and auth_header.lower().startswith("bearer "):
|
||||||
token = auth_header.split(" ", 1)[1]
|
token = auth_header.split(" ", 1)[1]
|
||||||
if not token:
|
else:
|
||||||
token = request.query_params.get("token")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
|
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
|
||||||
@@ -445,6 +444,7 @@ async def global_delete_attachment(
|
|||||||
if not can_delete:
|
if not can_delete:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||||
await attachment_crud.soft_delete_attachment(db, attachment)
|
await attachment_crud.soft_delete_attachment(db, attachment)
|
||||||
|
if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=attachment.study_id,
|
study_id=attachment.study_id,
|
||||||
@@ -497,6 +497,7 @@ async def delete_attachment(
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||||
|
|
||||||
await attachment_crud.soft_delete_attachment(db, attachment)
|
await attachment_crud.soft_delete_attachment(db, attachment)
|
||||||
|
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
|
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
|
from app.models.user import User
|
||||||
from app.schemas.audit import AuditEventCreate, AuditLogRead
|
from app.schemas.audit import AuditEventCreate, AuditLogRead
|
||||||
|
from app.services.ip_location import resolve_ip_location
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -40,6 +45,10 @@ async def list_audit_logs(
|
|||||||
entity_id: uuid.UUID | None = None,
|
entity_id: uuid.UUID | None = None,
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
operator_id: uuid.UUID | None = None,
|
operator_id: uuid.UUID | None = None,
|
||||||
|
client_ip: str | None = None,
|
||||||
|
client_type: str | None = None,
|
||||||
|
start_time: datetime | None = None,
|
||||||
|
end_time: datetime | None = None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
@@ -51,10 +60,51 @@ async def list_audit_logs(
|
|||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
action=action,
|
action=action,
|
||||||
operator_id=operator_id,
|
operator_id=operator_id,
|
||||||
|
client_ip=client_ip,
|
||||||
|
client_type=client_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
return list(logs)
|
operator_ids = {log.operator_id for log in logs}
|
||||||
|
operator_map: dict[uuid.UUID, User] = {}
|
||||||
|
if operator_ids:
|
||||||
|
result = await db.execute(select(User).where(User.id.in_(operator_ids)))
|
||||||
|
operator_map = {user.id: user for user in result.scalars().all()}
|
||||||
|
|
||||||
|
items: list[AuditLogRead] = []
|
||||||
|
for log in logs:
|
||||||
|
ip_location = resolve_ip_location(log.client_ip)
|
||||||
|
operator = operator_map.get(log.operator_id)
|
||||||
|
items.append(
|
||||||
|
AuditLogRead(
|
||||||
|
id=log.id,
|
||||||
|
study_id=log.study_id,
|
||||||
|
entity_type=log.entity_type,
|
||||||
|
entity_id=log.entity_id,
|
||||||
|
action=log.action,
|
||||||
|
detail=log.detail,
|
||||||
|
operator_id=log.operator_id,
|
||||||
|
operator_name=operator.full_name if operator else None,
|
||||||
|
operator_email=operator.email if operator else None,
|
||||||
|
operator_role=log.operator_role,
|
||||||
|
client_ip=log.client_ip,
|
||||||
|
ip_location=ip_location.location,
|
||||||
|
ip_country=ip_location.country,
|
||||||
|
ip_province=ip_location.province,
|
||||||
|
ip_city=ip_location.city,
|
||||||
|
ip_isp=ip_location.isp,
|
||||||
|
user_agent=log.user_agent,
|
||||||
|
client_type=log.client_type,
|
||||||
|
client_version=log.client_version,
|
||||||
|
client_platform=log.client_platform,
|
||||||
|
build_channel=log.build_channel,
|
||||||
|
build_commit=log.build_commit,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|||||||
+218
-15
@@ -1,18 +1,38 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from dataclasses import dataclass
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||||
from fastapi import File, UploadFile
|
from fastapi import File, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem
|
from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem
|
||||||
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
|
from app.core.request_context import resolve_client_ip, resolve_ctms_client_type
|
||||||
|
from app.core.security import create_access_token, decode_token, decode_token_allow_expired, oauth2_scheme, verify_password
|
||||||
from app.core.deps import get_current_user, get_db_session
|
from app.core.deps import get_current_user, get_db_session
|
||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
from app.models.user import UserStatus
|
from app.models.user import UserStatus
|
||||||
|
from app.schemas.email_settings import (
|
||||||
|
EmailCodeResponse,
|
||||||
|
EmailCodeVerifyResponse,
|
||||||
|
PasswordResetCodeVerifyRequest,
|
||||||
|
PasswordResetCodeVerifyResponse,
|
||||||
|
PasswordResetLinkSendRequest,
|
||||||
|
PasswordResetRequest,
|
||||||
|
PasswordResetTokenRequest,
|
||||||
|
RegisterEmailCodeSendRequest,
|
||||||
|
RegisterEmailCodeVerifyRequest,
|
||||||
|
)
|
||||||
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
|
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
|
||||||
|
from app.services import email_service
|
||||||
|
from app.services.user_login_sessions import (
|
||||||
|
create_login_session,
|
||||||
|
end_login_session,
|
||||||
|
session_id_from_payload,
|
||||||
|
touch_login_session,
|
||||||
|
)
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +59,14 @@ class ExtendResponse(BaseModel):
|
|||||||
expiresAt: datetime
|
expiresAt: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EmailAvailabilityResponse(BaseModel):
|
||||||
|
available: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EmailDomainsResponse(BaseModel):
|
||||||
|
items: list[str]
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
|
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
|
||||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -50,12 +78,59 @@ AVATAR_ALLOWED_CONTENT_TYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def issue_user_token(db_user) -> Token:
|
@dataclass(frozen=True)
|
||||||
|
class SessionPolicy:
|
||||||
|
access_minutes: int
|
||||||
|
absolute_max_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_session_client_type(value: str | None) -> str:
|
||||||
|
return "desktop" if (value or "").strip().lower() == "desktop" else "web"
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_policy_for_client_type(client_type: str) -> SessionPolicy:
|
||||||
|
if client_type == "desktop":
|
||||||
|
max_seconds = settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
|
||||||
|
return SessionPolicy(
|
||||||
|
access_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
|
||||||
|
absolute_max_seconds=max_seconds,
|
||||||
|
)
|
||||||
|
return SessionPolicy(
|
||||||
|
access_minutes=settings.JWT_EXPIRE_MINUTES,
|
||||||
|
absolute_max_seconds=settings.ABSOLUTE_SESSION_MAX_HOURS * 3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_session_client_type(request: Request) -> str:
|
||||||
|
return normalize_session_client_type(resolve_ctms_client_type(request.headers))
|
||||||
|
|
||||||
|
|
||||||
|
def policy_expires_at(issued_at: datetime, session_start: datetime, policy: SessionPolicy) -> datetime:
|
||||||
|
access_expires_at = issued_at + timedelta(minutes=policy.access_minutes)
|
||||||
|
session_expires_at = session_start + timedelta(seconds=policy.absolute_max_seconds)
|
||||||
|
return min(access_expires_at, session_expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
async def issue_user_token(db_user, request: Request, db: AsyncSession) -> Token:
|
||||||
session_start = datetime.now(timezone.utc)
|
session_start = datetime.now(timezone.utc)
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
client_type = get_request_session_client_type(request)
|
||||||
|
policy = get_session_policy_for_client_type(client_type)
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
user_id=str(db_user.id),
|
user_id=str(db_user.id),
|
||||||
expires_minutes=None,
|
expires_minutes=policy.access_minutes,
|
||||||
session_start=session_start,
|
session_start=session_start,
|
||||||
|
max_age_seconds=policy.absolute_max_seconds,
|
||||||
|
issued_at=session_start,
|
||||||
|
client_type=client_type,
|
||||||
|
session_id=str(session_id),
|
||||||
|
)
|
||||||
|
await create_login_session(
|
||||||
|
db,
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=db_user.id,
|
||||||
|
request=request,
|
||||||
|
login_at=session_start,
|
||||||
)
|
)
|
||||||
return Token(access_token=access_token, token_type="bearer")
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
|
|
||||||
@@ -108,6 +183,92 @@ def ensure_user_active(db_user) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email-domains", response_model=EmailDomainsResponse)
|
||||||
|
async def read_email_domains(
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> EmailDomainsResponse:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
rows = await email_service.list_email_settings(db)
|
||||||
|
return EmailDomainsResponse(items=[row.register_domain for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/register/email-availability", response_model=EmailAvailabilityResponse)
|
||||||
|
async def check_register_email_availability(
|
||||||
|
email: EmailStr = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> EmailAvailabilityResponse:
|
||||||
|
existing = await user_crud.get_by_email(db, str(email))
|
||||||
|
return EmailAvailabilityResponse(available=existing is None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register/email-code/send", response_model=EmailCodeResponse)
|
||||||
|
async def send_register_email_code(
|
||||||
|
payload: RegisterEmailCodeSendRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> EmailCodeResponse:
|
||||||
|
await email_service.send_register_code(db, str(payload.email))
|
||||||
|
return EmailCodeResponse(message="验证码已发送")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register/email-code/verify", response_model=EmailCodeVerifyResponse)
|
||||||
|
async def verify_register_email_code(
|
||||||
|
payload: RegisterEmailCodeVerifyRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> EmailCodeVerifyResponse:
|
||||||
|
await email_service.verify_register_code(db, str(payload.email), payload.code)
|
||||||
|
return EmailCodeVerifyResponse(verified=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset/email-code/send", response_model=EmailCodeResponse)
|
||||||
|
async def send_password_reset_email_code(
|
||||||
|
payload: PasswordResetLinkSendRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> EmailCodeResponse:
|
||||||
|
await email_service.send_password_reset_code(db, str(payload.email))
|
||||||
|
return EmailCodeResponse(message="验证码发送成功,请查收邮箱")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset/email-code/verify", response_model=PasswordResetCodeVerifyResponse)
|
||||||
|
async def verify_password_reset_email_code(
|
||||||
|
payload: PasswordResetCodeVerifyRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> PasswordResetCodeVerifyResponse:
|
||||||
|
reset_token = await email_service.verify_password_reset_code(db, str(payload.email), payload.code)
|
||||||
|
return PasswordResetCodeVerifyResponse(verified=True, reset_token=reset_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset-link/send", response_model=EmailCodeResponse)
|
||||||
|
async def send_password_reset_link(
|
||||||
|
payload: PasswordResetLinkSendRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> EmailCodeResponse:
|
||||||
|
await email_service.send_password_reset_link(
|
||||||
|
db,
|
||||||
|
str(payload.email),
|
||||||
|
frontend_origin=settings.FRONTEND_PUBLIC_URL,
|
||||||
|
)
|
||||||
|
return EmailCodeResponse(message="如果账号存在,重置链接已发送")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset", response_model=EmailCodeResponse)
|
||||||
|
async def reset_password(
|
||||||
|
payload: PasswordResetRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> EmailCodeResponse:
|
||||||
|
await email_service.reset_password_with_code(db, str(payload.email), payload.code, payload.password)
|
||||||
|
return EmailCodeResponse(message="密码已重置,请返回登录")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset-link", response_model=EmailCodeResponse)
|
||||||
|
async def reset_password_with_link(
|
||||||
|
payload: PasswordResetTokenRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> EmailCodeResponse:
|
||||||
|
await email_service.reset_password_with_token(db, payload.token, payload.password)
|
||||||
|
return EmailCodeResponse(message="密码已重置,请返回登录")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||||
async def register(
|
async def register(
|
||||||
payload: UserRegisterRequest,
|
payload: UserRegisterRequest,
|
||||||
@@ -116,8 +277,9 @@ async def register(
|
|||||||
existing = await user_crud.get_by_email(db, payload.email)
|
existing = await user_crud.get_by_email(db, payload.email)
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
|
||||||
await user_crud.create_pending_user(db, payload)
|
await email_service.ensure_register_email_verified(db, str(payload.email))
|
||||||
return {"message": "注册成功,等待管理员审核"}
|
await user_crud.create_registered_user(db, payload)
|
||||||
|
return {"message": "注册成功,请登录"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login-key", response_model=LoginKeyResponse)
|
@router.get("/login-key", response_model=LoginKeyResponse)
|
||||||
@@ -133,23 +295,23 @@ async def get_login_key() -> LoginKeyResponse:
|
|||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
async def login_for_access_token(
|
async def login_for_access_token(
|
||||||
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
|
payload: LoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
|
||||||
) -> Token:
|
) -> Token:
|
||||||
db_user = await authenticate_encrypted_password(payload, db)
|
db_user = await authenticate_encrypted_password(payload, db)
|
||||||
ensure_user_active(db_user)
|
ensure_user_active(db_user)
|
||||||
|
|
||||||
return issue_user_token(db_user)
|
return await issue_user_token(db_user, request, db)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/dev-login", response_model=Token)
|
@router.post("/dev-login", response_model=Token)
|
||||||
async def dev_login_for_access_token(
|
async def dev_login_for_access_token(
|
||||||
payload: DevLoginRequest, db: AsyncSession = Depends(get_db_session)
|
payload: DevLoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
|
||||||
) -> Token:
|
) -> Token:
|
||||||
if settings.ENV != "development":
|
if settings.ENV != "development":
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||||
db_user = await authenticate_plain_password(payload, db)
|
db_user = await authenticate_plain_password(payload, db)
|
||||||
ensure_user_active(db_user)
|
ensure_user_active(db_user)
|
||||||
return issue_user_token(db_user)
|
return await issue_user_token(db_user, request, db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserRead)
|
@router.get("/me", response_model=UserRead)
|
||||||
@@ -178,22 +340,63 @@ async def extend_access_token(
|
|||||||
if db_user.status != UserStatus.ACTIVE:
|
if db_user.status != UserStatus.ACTIVE:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
|
||||||
session_start_ts = payload.get("orig_iat") or payload.get("iat")
|
session_start_ts = payload.get("orig_iat") or payload.get("iat")
|
||||||
|
policy = get_session_policy_for_client_type(normalize_session_client_type(payload.get("client_type")))
|
||||||
if session_start_ts:
|
if session_start_ts:
|
||||||
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
|
if now_ts - int(session_start_ts) > policy.absolute_max_seconds:
|
||||||
if now_ts - int(session_start_ts) > max_seconds:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
|
||||||
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
|
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
|
||||||
else:
|
else:
|
||||||
session_start = datetime.now(timezone.utc)
|
session_start = datetime.now(timezone.utc)
|
||||||
|
issued_at = datetime.now(timezone.utc)
|
||||||
new_token = create_access_token(
|
new_token = create_access_token(
|
||||||
user_id=str(db_user.id),
|
user_id=str(db_user.id),
|
||||||
expires_minutes=None,
|
expires_minutes=policy.access_minutes,
|
||||||
session_start=session_start,
|
session_start=session_start,
|
||||||
|
max_age_seconds=policy.absolute_max_seconds,
|
||||||
|
issued_at=issued_at,
|
||||||
|
client_type=normalize_session_client_type(payload.get("client_type")),
|
||||||
|
session_id=str(session_id_from_payload(payload)),
|
||||||
)
|
)
|
||||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
expires_at = policy_expires_at(issued_at, session_start, policy)
|
||||||
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
|
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/session/heartbeat")
|
||||||
|
async def heartbeat_login_session(
|
||||||
|
request: Request,
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
session = await touch_login_session(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=decode_token(token),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录会话已结束")
|
||||||
|
return {
|
||||||
|
"status": "online",
|
||||||
|
"last_seen_at": session.last_seen_at.isoformat(),
|
||||||
|
"client_ip": resolve_client_ip(request),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/session/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def logout_login_session(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
await end_login_session(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=decode_token(token),
|
||||||
|
)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me", response_model=UserRead)
|
@router.patch("/me", response_model=UserRead)
|
||||||
async def update_me(
|
async def update_me(
|
||||||
payload: UserSelfUpdate,
|
payload: UserSelfUpdate,
|
||||||
|
|||||||
@@ -0,0 +1,629 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, Header, Request, Response, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session, require_api_permission
|
||||||
|
from app.schemas.collaboration import (
|
||||||
|
CollaborationCallbackPayload,
|
||||||
|
CollaborationCandidateRead,
|
||||||
|
CollaborationEditorConfigRead,
|
||||||
|
CollaborationEditRequestRead,
|
||||||
|
CollaborationEditRequestResolve,
|
||||||
|
CollaborationExportRecord,
|
||||||
|
CollaborationFileCreate,
|
||||||
|
CollaborationFileRead,
|
||||||
|
CollaborationFileUpdate,
|
||||||
|
CollaborationFolderCreate,
|
||||||
|
CollaborationFolderRead,
|
||||||
|
CollaborationFolderUpdate,
|
||||||
|
CollaborationMemberRead,
|
||||||
|
CollaborationMemberUpsert,
|
||||||
|
CollaborationOwnershipTransferRequest,
|
||||||
|
CollaborationPublicEditorConfigRequest,
|
||||||
|
CollaborationPublicShareMetadata,
|
||||||
|
CollaborationRestoreRequest,
|
||||||
|
CollaborationRevisionCopyRequest,
|
||||||
|
CollaborationRevisionRead,
|
||||||
|
CollaborationRevisionUpdate,
|
||||||
|
CollaborationShareAccessGrant,
|
||||||
|
CollaborationShareLinkRead,
|
||||||
|
CollaborationShareLinkUpdate,
|
||||||
|
CollaborationSharePasswordRequest,
|
||||||
|
)
|
||||||
|
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||||
|
from app.services import collaboration_service, collaboration_share_service, onlyoffice_collaboration_service, onlyoffice_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
public_router = APIRouter()
|
||||||
|
internal_router = APIRouter(include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/folders",
|
||||||
|
response_model=list[CollaborationFolderRead],
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def list_folders(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||||
|
return await collaboration_service.list_folders(db, study_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/folders",
|
||||||
|
response_model=CollaborationFolderRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:manage"))],
|
||||||
|
)
|
||||||
|
async def create_folder(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
payload: CollaborationFolderCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return await collaboration_service.create_folder(db, study_id, payload, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/folders/{folder_id}",
|
||||||
|
response_model=CollaborationFolderRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:manage"))],
|
||||||
|
)
|
||||||
|
async def update_folder(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
payload: CollaborationFolderUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
return await collaboration_service.update_folder(db, study_id, folder_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/folders/{folder_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:manage"))],
|
||||||
|
)
|
||||||
|
async def delete_folder(study_id: uuid.UUID, folder_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||||
|
await collaboration_service.delete_folder(db, study_id, folder_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/files",
|
||||||
|
response_model=list[CollaborationFileRead],
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def list_files(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
folder_id: uuid.UUID | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
deleted: bool = False,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return await collaboration_service.list_files(
|
||||||
|
db, study_id, current_user, folder_id=folder_id, keyword=keyword, deleted=deleted
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files",
|
||||||
|
response_model=CollaborationFileRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:create"))],
|
||||||
|
)
|
||||||
|
async def create_file(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
payload: CollaborationFileCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.create_blank_file(db, study_id, payload, current_user)
|
||||||
|
return await collaboration_service.file_read(db, item, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/import",
|
||||||
|
response_model=CollaborationFileRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:create"))],
|
||||||
|
)
|
||||||
|
async def import_file(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
folder_id: uuid.UUID | None = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.import_file(db, study_id, folder_id, file, current_user)
|
||||||
|
return await collaboration_service.file_read(db, item, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/{file_id}/copy",
|
||||||
|
response_model=CollaborationFileRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:create"))],
|
||||||
|
)
|
||||||
|
async def copy_file(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
source = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
item = await collaboration_service.copy_file(db, source, current_user)
|
||||||
|
return await collaboration_service.file_read(db, item, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/files/{file_id}",
|
||||||
|
response_model=CollaborationFileRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def get_file(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
return await collaboration_service.file_read(db, item, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/files/{file_id}/download",
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def download_file(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
revision = await collaboration_service.prepare_download(db, item, current_user)
|
||||||
|
return FileResponse(
|
||||||
|
path=revision.file_uri,
|
||||||
|
media_type=revision.mime_type,
|
||||||
|
filename=item.title,
|
||||||
|
content_disposition_type="attachment",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/files/{file_id}",
|
||||||
|
response_model=CollaborationFileRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def update_file(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
payload: CollaborationFileUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
item = await collaboration_service.update_file(db, item, payload, current_user)
|
||||||
|
return await collaboration_service.file_read(db, item, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/files/{file_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def trash_file(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
await collaboration_service.move_to_trash(db, item, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/{file_id}/restore",
|
||||||
|
response_model=CollaborationFileRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def restore_file(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id, include_deleted=True)
|
||||||
|
item = await collaboration_service.restore_file(db, item, current_user)
|
||||||
|
return await collaboration_service.file_read(db, item, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/files/{file_id}/members",
|
||||||
|
response_model=list[CollaborationMemberRead],
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def list_members(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
return await collaboration_service.list_members(db, item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/files/{file_id}/members",
|
||||||
|
response_model=CollaborationMemberRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def upsert_member(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
payload: CollaborationMemberUpsert,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
return await collaboration_service.upsert_member(db, item, payload, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/files/{file_id}/members/{user_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def remove_member(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
await collaboration_service.remove_member(db, item, user_id, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/{file_id}/edit-requests",
|
||||||
|
response_model=CollaborationEditRequestRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def create_edit_request(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
return await collaboration_service.create_edit_request(db, item, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/files/{file_id}/edit-requests",
|
||||||
|
response_model=list[CollaborationEditRequestRead],
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def list_edit_requests(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
pending_only: bool = True,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
return await collaboration_service.list_edit_requests(db, item, current_user, pending_only=pending_only)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/{file_id}/edit-requests/{request_id}/resolve",
|
||||||
|
response_model=CollaborationEditRequestRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def resolve_edit_request(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
payload: CollaborationEditRequestResolve,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/{file_id}/transfer-ownership",
|
||||||
|
response_model=CollaborationFileRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def transfer_ownership(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
payload: CollaborationOwnershipTransferRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
item = await collaboration_service.transfer_ownership(db, item, payload, current_user)
|
||||||
|
return await collaboration_service.file_read(db, item, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/files/{file_id}/share-link",
|
||||||
|
response_model=CollaborationShareLinkRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def get_share_link(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
result = await collaboration_share_service.get_share_link(db, item, current_user)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/files/{file_id}/share-link",
|
||||||
|
response_model=CollaborationShareLinkRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def update_share_link(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
payload: CollaborationShareLinkUpdate,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
result = await collaboration_share_service.update_share_link(db, item, payload, current_user)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/member-candidates",
|
||||||
|
response_model=list[CollaborationCandidateRead],
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def list_member_candidates(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||||
|
return await collaboration_service.list_candidates(db, study_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/files/{file_id}/revisions",
|
||||||
|
response_model=list[CollaborationRevisionRead],
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def list_revisions(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
return await collaboration_service.list_revisions(db, item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/files/{file_id}/revisions/{revision_id}",
|
||||||
|
response_model=CollaborationRevisionRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def update_revision(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
revision_id: uuid.UUID,
|
||||||
|
payload: CollaborationRevisionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
return await collaboration_service.update_revision(db, item, revision_id, payload, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/files/{file_id}/revisions/{revision_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def delete_revision(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
revision_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
await collaboration_service.delete_revision(db, item, revision_id, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/{file_id}/revisions/{revision_id}/copy",
|
||||||
|
response_model=CollaborationFileRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[
|
||||||
|
Depends(require_api_permission("collaboration:create")),
|
||||||
|
Depends(require_api_permission("collaboration:read")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def copy_revision(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
revision_id: uuid.UUID,
|
||||||
|
payload: CollaborationRevisionCopyRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
copied = await collaboration_service.copy_revision(db, item, revision_id, payload, current_user)
|
||||||
|
return await collaboration_service.file_read(db, copied, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/files/{file_id}/revisions/{revision_id}/preview-config",
|
||||||
|
response_model=OnlyOfficePreviewConfigRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def get_revision_preview_config(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
revision_id: uuid.UUID,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, current_user)
|
||||||
|
await onlyoffice_service.ensure_onlyoffice_available()
|
||||||
|
result = onlyoffice_service.build_preview_config(
|
||||||
|
resource_type="collaboration_revision",
|
||||||
|
resource_id=revision.id,
|
||||||
|
file_name=item.title,
|
||||||
|
file_hash=revision.file_hash,
|
||||||
|
user_id=current_user.id,
|
||||||
|
user_name=current_user.full_name,
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/{file_id}/revisions/{revision_id}/restore",
|
||||||
|
response_model=CollaborationRevisionRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def restore_revision(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
revision_id: uuid.UUID,
|
||||||
|
payload: CollaborationRestoreRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
return await collaboration_service.restore_revision(
|
||||||
|
db, item, revision_id, current_user, payload.change_summary
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/files/{file_id}/editor-config",
|
||||||
|
response_model=CollaborationEditorConfigRead,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def get_editor_config(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
result = await onlyoffice_collaboration_service.build_editor_config(db, item, current_user)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/{file_id}/exports",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def record_export(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
payload: CollaborationExportRecord,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
await collaboration_service.record_export(db, item, current_user, payload.file_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files/{file_id}/downloads",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||||
|
)
|
||||||
|
async def record_download(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
payload: CollaborationExportRecord,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||||
|
await collaboration_service.record_download(db, item, current_user, payload.file_type)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.get("/metadata", response_model=CollaborationPublicShareMetadata)
|
||||||
|
async def get_public_share_metadata(
|
||||||
|
response: Response,
|
||||||
|
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
result = await collaboration_share_service.public_metadata(db, x_ctms_share_token)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.post("/access", response_model=CollaborationShareAccessGrant)
|
||||||
|
async def verify_public_share_password(
|
||||||
|
payload: CollaborationSharePasswordRequest,
|
||||||
|
response: Response,
|
||||||
|
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
result = await collaboration_share_service.verify_share_password(
|
||||||
|
db, x_ctms_share_token, payload.password
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.post("/editor-config", response_model=CollaborationEditorConfigRead)
|
||||||
|
async def get_public_share_editor_config(
|
||||||
|
payload: CollaborationPublicEditorConfigRequest,
|
||||||
|
response: Response,
|
||||||
|
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
link, item = await collaboration_share_service.resolve_active_share(db, x_ctms_share_token)
|
||||||
|
collaboration_share_service.validate_access_grant(link, payload.access_token)
|
||||||
|
result = await onlyoffice_collaboration_service.build_shared_editor_config(
|
||||||
|
db,
|
||||||
|
item,
|
||||||
|
link,
|
||||||
|
client_id=payload.client_id,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@internal_router.get("/internal/onlyoffice/collaboration/sessions/{session_id}/content")
|
||||||
|
async def get_session_content(session_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session)):
|
||||||
|
revision, item = await onlyoffice_collaboration_service.get_session_content(
|
||||||
|
db, session_id, request.headers.get("AuthorizationJwt")
|
||||||
|
)
|
||||||
|
return FileResponse(
|
||||||
|
path=revision.file_uri,
|
||||||
|
media_type=revision.mime_type,
|
||||||
|
filename=item.title,
|
||||||
|
content_disposition_type="inline",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@internal_router.post("/internal/onlyoffice/collaboration/sessions/{session_id}/callback")
|
||||||
|
async def collaboration_callback(
|
||||||
|
session_id: uuid.UUID,
|
||||||
|
payload: CollaborationCallbackPayload,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
onlyoffice_collaboration_service.validate_callback_token(
|
||||||
|
request.headers.get("AuthorizationJwt"), payload
|
||||||
|
)
|
||||||
|
return await onlyoffice_collaboration_service.process_callback(db, session_id, payload)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session
|
||||||
|
from app.schemas.notification import (
|
||||||
|
DesktopNotificationAckRequest,
|
||||||
|
DesktopNotificationClaimRequest,
|
||||||
|
DesktopNotificationClaimResponse,
|
||||||
|
DesktopNotificationSubscriptionRead,
|
||||||
|
DesktopNotificationSubscriptionUpdate,
|
||||||
|
)
|
||||||
|
from app.services import desktop_notification_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscription", response_model=DesktopNotificationSubscriptionRead)
|
||||||
|
async def read_subscription(
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> DesktopNotificationSubscriptionRead:
|
||||||
|
subscription = await desktop_notification_service.get_subscription(db, current_user.id)
|
||||||
|
return DesktopNotificationSubscriptionRead(
|
||||||
|
enabled=bool(subscription and subscription.enabled),
|
||||||
|
enabled_at=subscription.enabled_at if subscription else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/subscription", response_model=DesktopNotificationSubscriptionRead)
|
||||||
|
async def update_subscription(
|
||||||
|
payload: DesktopNotificationSubscriptionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> DesktopNotificationSubscriptionRead:
|
||||||
|
subscription = await desktop_notification_service.set_subscription(
|
||||||
|
db, current_user.id, payload.enabled
|
||||||
|
)
|
||||||
|
return DesktopNotificationSubscriptionRead(
|
||||||
|
enabled=subscription.enabled,
|
||||||
|
enabled_at=subscription.enabled_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/claim", response_model=DesktopNotificationClaimResponse)
|
||||||
|
async def claim_notifications(
|
||||||
|
payload: DesktopNotificationClaimRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> DesktopNotificationClaimResponse:
|
||||||
|
if payload.limit < 1 or payload.limit > 50:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="limit 必须在 1 到 50 之间")
|
||||||
|
claim_token, lease_expires_at, items = await desktop_notification_service.claim_notifications(
|
||||||
|
db, current_user.id, payload.limit
|
||||||
|
)
|
||||||
|
return DesktopNotificationClaimResponse(
|
||||||
|
claim_token=claim_token,
|
||||||
|
lease_expires_at=lease_expires_at,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ack", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def acknowledge_notifications(
|
||||||
|
payload: DesktopNotificationAckRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await desktop_notification_service.acknowledge_notifications(
|
||||||
|
db,
|
||||||
|
current_user.id,
|
||||||
|
payload.claim_token,
|
||||||
|
payload.delivered_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{notification_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def mark_notification_read(
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await desktop_notification_service.mark_notification_read(
|
||||||
|
db, current_user.id, notification_id
|
||||||
|
)
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
|
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||||
from app.core.project_permissions import role_has_api_permission
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import faq_category as category_crud
|
from app.crud import faq_category as category_crud
|
||||||
from app.crud import faq_item as item_crud
|
from app.crud import faq_item as item_crud
|
||||||
from app.crud import member as member_crud
|
|
||||||
from app.schemas.common import PaginatedResponse
|
from app.schemas.common import PaginatedResponse
|
||||||
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
|
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
|
||||||
from app.utils.pagination import paginate
|
from app.utils.pagination import paginate
|
||||||
@@ -47,16 +43,6 @@ async def create_category(
|
|||||||
if dup:
|
if dup:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
||||||
category = await category_crud.create_category(db, payload)
|
category = await category_crud.create_category(db, payload)
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=payload.study_id,
|
|
||||||
entity_type="faq_category",
|
|
||||||
entity_id=category.id,
|
|
||||||
action="CREATE_FAQ_CATEGORY",
|
|
||||||
detail=json.dumps({"targetName": category.name, "description": f"创建“{category.name}”分类"}, ensure_ascii=False),
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
|
||||||
)
|
|
||||||
return CategoryRead.model_validate(category)
|
return CategoryRead.model_validate(category)
|
||||||
|
|
||||||
|
|
||||||
@@ -112,16 +98,6 @@ async def update_category(
|
|||||||
if not target_study_id:
|
if not target_study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||||
updated = await category_crud.update_category(db, category, payload)
|
updated = await category_crud.update_category(db, category, payload)
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=updated.study_id,
|
|
||||||
entity_type="faq_category",
|
|
||||||
entity_id=category_id,
|
|
||||||
action="UPDATE_FAQ_CATEGORY",
|
|
||||||
detail=json.dumps({"targetName": updated.name, "description": f"更新“{updated.name}”分类"}, ensure_ascii=False),
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, updated.study_id, current_user),
|
|
||||||
)
|
|
||||||
return CategoryRead.model_validate(updated)
|
return CategoryRead.model_validate(updated)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,16 +124,5 @@ async def delete_category(
|
|||||||
item_count = await item_crud.count_items_by_category(db, category_id)
|
item_count = await item_crud.count_items_by_category(db, category_id)
|
||||||
if item_count > 0:
|
if item_count > 0:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
|
||||||
category_name = category.name
|
|
||||||
await db.delete(category)
|
await db.delete(category)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=category.study_id,
|
|
||||||
entity_type="faq_category",
|
|
||||||
entity_id=category_id,
|
|
||||||
action="DELETE_FAQ_CATEGORY",
|
|
||||||
detail=json.dumps({"targetName": category_name, "description": f"删除“{category_name}”分类"}, ensure_ascii=False),
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, category.study_id, current_user),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import json
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
|
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||||
from app.core.project_permissions import role_has_api_permission
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import faq_category as category_crud
|
from app.crud import faq_category as category_crud
|
||||||
from app.crud import faq_item as faq_crud
|
from app.crud import faq_item as faq_crud
|
||||||
from app.crud import faq_reply as reply_crud
|
from app.crud import faq_reply as reply_crud
|
||||||
@@ -31,13 +28,6 @@ def _is_system_admin(current_user) -> bool:
|
|||||||
return is_system_admin(current_user)
|
return is_system_admin(current_user)
|
||||||
|
|
||||||
|
|
||||||
def _compact_text(value: str | None, max_length: int = 40) -> str:
|
|
||||||
text = " ".join(str(value or "").split())
|
|
||||||
if len(text) <= max_length:
|
|
||||||
return text
|
|
||||||
return f"{text[:max_length]}..."
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/",
|
"/",
|
||||||
response_model=FaqRead,
|
response_model=FaqRead,
|
||||||
@@ -74,17 +64,6 @@ async def create_faq(
|
|||||||
reply_in=FaqReplyCreate(content=payload.answer),
|
reply_in=FaqReplyCreate(content=payload.answer),
|
||||||
)
|
)
|
||||||
await faq_crud.set_status(db, item.id, "PROCESSING")
|
await faq_crud.set_status(db, item.id, "PROCESSING")
|
||||||
question_name = _compact_text(item.question)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=payload.study_id,
|
|
||||||
entity_type="faq_item",
|
|
||||||
entity_id=item.id,
|
|
||||||
action="CREATE_FAQ_ITEM",
|
|
||||||
detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
|
||||||
)
|
|
||||||
return FaqRead.model_validate(item)
|
return FaqRead.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
@@ -178,19 +157,6 @@ async def update_faq(
|
|||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||||
updated = await faq_crud.update_item(db, item, payload)
|
updated = await faq_crud.update_item(db, item, payload)
|
||||||
action = "UPDATE_FAQ_ITEM"
|
|
||||||
question_name = _compact_text(updated.question)
|
|
||||||
detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}”"}, ensure_ascii=False)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=item.study_id,
|
|
||||||
entity_type="faq_item",
|
|
||||||
entity_id=item_id,
|
|
||||||
action=action,
|
|
||||||
detail=detail,
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
|
||||||
)
|
|
||||||
return FaqRead.model_validate(updated)
|
return FaqRead.model_validate(updated)
|
||||||
|
|
||||||
|
|
||||||
@@ -337,17 +303,6 @@ async def create_reply(
|
|||||||
if item.status != "RESOLVED":
|
if item.status != "RESOLVED":
|
||||||
await faq_crud.set_status(db, item.id, "PROCESSING")
|
await faq_crud.set_status(db, item.id, "PROCESSING")
|
||||||
await faq_crud.touch_item(db, item.id)
|
await faq_crud.touch_item(db, item.id)
|
||||||
question_name = _compact_text(item.question)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=item.study_id,
|
|
||||||
entity_type="faq_reply",
|
|
||||||
entity_id=reply.id,
|
|
||||||
action="CREATE_FAQ_REPLY",
|
|
||||||
detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
|
||||||
)
|
|
||||||
data = FaqReplyRead.model_validate(reply)
|
data = FaqReplyRead.model_validate(reply)
|
||||||
if quote:
|
if quote:
|
||||||
if quote.is_deleted:
|
if quote.is_deleted:
|
||||||
@@ -380,20 +335,9 @@ async def delete_faq(
|
|||||||
item = await faq_crud.get_item(db, item_id)
|
item = await faq_crud.get_item(db, item_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||||
question_name = _compact_text(item.question)
|
|
||||||
await reply_crud.delete_replies_by_faq_id(db, item.id)
|
await reply_crud.delete_replies_by_faq_id(db, item.id)
|
||||||
await db.delete(item)
|
await db.delete(item)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=item.study_id,
|
|
||||||
entity_type="faq_item",
|
|
||||||
entity_id=item_id,
|
|
||||||
action="DELETE_FAQ_ITEM",
|
|
||||||
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -415,7 +359,6 @@ async def delete_reply(
|
|||||||
item = await faq_crud.get_item(db, item_id)
|
item = await faq_crud.get_item(db, item_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||||
question_name = _compact_text(item.question)
|
|
||||||
reply = await reply_crud.get_reply(db, reply_id)
|
reply = await reply_crud.get_reply(db, reply_id)
|
||||||
if not reply or reply.faq_id != item.id:
|
if not reply or reply.faq_id != item.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
|
||||||
@@ -438,13 +381,3 @@ async def delete_reply(
|
|||||||
resolved_by_confirm=False,
|
resolved_by_confirm=False,
|
||||||
)
|
)
|
||||||
await faq_crud.touch_item(db, item.id)
|
await faq_crud.touch_item(db, item.id)
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=item.study_id,
|
|
||||||
entity_type="faq_reply",
|
|
||||||
entity_id=reply_id,
|
|
||||||
action="DELETE_FAQ_REPLY",
|
|
||||||
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False),
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Response, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_db_session, get_current_user, require_study_member
|
from app.core.deps import get_db_session, get_current_user, require_study_member
|
||||||
from app.schemas.notification import NotificationItem
|
from app.schemas.notification import GeneralNotificationFeed, GeneralNotificationRead, NotificationItem
|
||||||
from app.services import document_service
|
from app.services import document_service, notification_service, project_reminder_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -29,3 +29,65 @@ async def list_notifications(
|
|||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/notifications/feed",
|
||||||
|
response_model=GeneralNotificationFeed,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_general_notifications(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 10,
|
||||||
|
category: str | None = None,
|
||||||
|
unread_only: bool = False,
|
||||||
|
requires_action: bool | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> GeneralNotificationFeed:
|
||||||
|
# Fail closed: stale reminders may contain details for a permission that was just revoked.
|
||||||
|
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
|
||||||
|
return await notification_service.list_feed(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=current_user.id,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
category=category,
|
||||||
|
unread_only=unread_only,
|
||||||
|
requires_action=requires_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/notifications/{notification_id}/read",
|
||||||
|
response_model=GeneralNotificationRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def mark_general_notification_read(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> GeneralNotificationRead:
|
||||||
|
return await notification_service.mark_read(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=current_user.id,
|
||||||
|
notification_id=notification_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/notifications/read-all",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def mark_all_general_notifications_read(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> Response:
|
||||||
|
await notification_service.mark_all_read(db, study_id=study_id, recipient_id=current_user.id)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists
|
||||||
|
from app.core.deps import get_current_user, get_db_session
|
||||||
|
from app.crud import attachment as attachment_crud
|
||||||
|
from app.crud import document as document_crud
|
||||||
|
from app.crud import document_version as version_crud
|
||||||
|
from app.models.collaboration import CollaborationRevision
|
||||||
|
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||||
|
from app.services import document_service, onlyoffice_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
internal_router = APIRouter(include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _content_disposition(filename: str) -> str:
|
||||||
|
fallback = "".join(
|
||||||
|
character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_"
|
||||||
|
for character in filename
|
||||||
|
) or "document"
|
||||||
|
encoded = quote(filename, safe="")
|
||||||
|
return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/attachments/{attachment_id}/config",
|
||||||
|
response_model=OnlyOfficePreviewConfigRead,
|
||||||
|
)
|
||||||
|
async def get_attachment_preview_config(
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> OnlyOfficePreviewConfigRead:
|
||||||
|
attachment = await attachment_crud.get_attachment(db, attachment_id)
|
||||||
|
if not attachment:
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"ATTACHMENT_NOT_FOUND", "附件不存在", status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
await _ensure_study_exists(db, attachment.study_id)
|
||||||
|
await _ensure_attachment_permission(
|
||||||
|
db,
|
||||||
|
attachment.study_id,
|
||||||
|
attachment.entity_type,
|
||||||
|
attachment.entity_id,
|
||||||
|
"read",
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
|
if not os.path.exists(attachment.file_path):
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"ATTACHMENT_FILE_NOT_FOUND", "服务器未找到文件", status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
if not onlyoffice_service.office_format_for_filename(attachment.filename):
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"ONLYOFFICE_FORMAT_UNSUPPORTED",
|
||||||
|
"该文件格式不支持 Office 在线预览",
|
||||||
|
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
)
|
||||||
|
await onlyoffice_service.ensure_onlyoffice_available()
|
||||||
|
result = onlyoffice_service.build_preview_config(
|
||||||
|
resource_type="attachment",
|
||||||
|
resource_id=attachment.id,
|
||||||
|
file_name=attachment.filename,
|
||||||
|
user_id=current_user.id,
|
||||||
|
user_name=current_user.full_name,
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/versions/{version_id}/config",
|
||||||
|
response_model=OnlyOfficePreviewConfigRead,
|
||||||
|
)
|
||||||
|
async def get_version_preview_config(
|
||||||
|
version_id: uuid.UUID,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> OnlyOfficePreviewConfigRead:
|
||||||
|
version = await version_crud.get(db, version_id)
|
||||||
|
if not version:
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
document = await document_crud.get(db, version.document_id)
|
||||||
|
if not document:
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"DOCUMENT_NOT_FOUND", "文档不存在", status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
await document_service._ensure_study_access(db, document.trial_id, current_user, action="view")
|
||||||
|
file_path = Path(version.file_uri)
|
||||||
|
if not file_path.exists():
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
file_name = version.original_filename or document_service._legacy_download_filename(version, document)
|
||||||
|
if not onlyoffice_service.office_format_for_filename(file_name):
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"ONLYOFFICE_FORMAT_UNSUPPORTED",
|
||||||
|
"该文件格式不支持 Office 在线预览",
|
||||||
|
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
)
|
||||||
|
await onlyoffice_service.ensure_onlyoffice_available()
|
||||||
|
result = onlyoffice_service.build_preview_config(
|
||||||
|
resource_type="version",
|
||||||
|
resource_id=version.id,
|
||||||
|
file_name=file_name,
|
||||||
|
file_hash=version.file_hash,
|
||||||
|
user_id=current_user.id,
|
||||||
|
user_name=current_user.full_name,
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize_internal_file_request(request: Request, expected_url: str) -> None:
|
||||||
|
onlyoffice_service.validate_outbox_token(request.headers.get("AuthorizationJwt"), expected_url)
|
||||||
|
|
||||||
|
|
||||||
|
@internal_router.get("/internal/onlyoffice/attachments/{attachment_id}/content")
|
||||||
|
async def get_internal_attachment_content(
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> FileResponse:
|
||||||
|
expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment_id)
|
||||||
|
_authorize_internal_file_request(request, expected_url)
|
||||||
|
attachment = await attachment_crud.get_attachment(db, attachment_id)
|
||||||
|
if not attachment or not os.path.exists(attachment.file_path):
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"ATTACHMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
return FileResponse(
|
||||||
|
path=attachment.file_path,
|
||||||
|
media_type=attachment.content_type or "application/octet-stream",
|
||||||
|
headers={"Content-Disposition": _content_disposition(attachment.filename)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@internal_router.get("/internal/onlyoffice/versions/{version_id}/content")
|
||||||
|
async def get_internal_version_content(
|
||||||
|
version_id: uuid.UUID,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> FileResponse:
|
||||||
|
expected_url = onlyoffice_service.onlyoffice_content_url("version", version_id)
|
||||||
|
_authorize_internal_file_request(request, expected_url)
|
||||||
|
version = await version_crud.get(db, version_id)
|
||||||
|
if not version:
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
document = await document_crud.get(db, version.document_id)
|
||||||
|
file_path = Path(version.file_uri)
|
||||||
|
if not document or not file_path.exists():
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
file_name = version.original_filename or document_service._legacy_download_filename(version, document)
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path),
|
||||||
|
media_type=version.mime_type or "application/octet-stream",
|
||||||
|
headers={"Content-Disposition": _content_disposition(file_name)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@internal_router.get("/internal/onlyoffice/collaboration-revisions/{revision_id}/content")
|
||||||
|
async def get_internal_collaboration_revision_content(
|
||||||
|
revision_id: uuid.UUID,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> FileResponse:
|
||||||
|
expected_url = onlyoffice_service.onlyoffice_content_url("collaboration_revision", revision_id)
|
||||||
|
_authorize_internal_file_request(request, expected_url)
|
||||||
|
revision = await db.get(CollaborationRevision, revision_id)
|
||||||
|
file_path = Path(revision.file_uri) if revision else None
|
||||||
|
if not revision or getattr(revision, "deleted_at", None) is not None or not file_path or not file_path.exists():
|
||||||
|
raise onlyoffice_service.onlyoffice_error(
|
||||||
|
"COLLABORATION_REVISION_NOT_FOUND", "协作修订不存在", status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path),
|
||||||
|
media_type=revision.mime_type or "application/octet-stream",
|
||||||
|
headers={"Content-Disposition": _content_disposition(revision.original_filename)},
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,9 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import precaution as precaution_crud
|
from app.crud import precaution as precaution_crud
|
||||||
from app.crud import site as site_crud
|
from app.crud import site as site_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
@@ -29,11 +27,6 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
def _precaution_audit_detail(action: str, precaution) -> str:
|
|
||||||
title = str(precaution.title or "").strip() or "注意事项"
|
|
||||||
return json.dumps({"targetName": title, "description": f"{action}注意事项“{title}”"}, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/precautions",
|
"/precautions",
|
||||||
response_model=PrecautionRead,
|
response_model=PrecautionRead,
|
||||||
@@ -49,16 +42,6 @@ async def create_precaution(
|
|||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
await _ensure_site_name_active(db, study_id, precaution_in.site_name)
|
await _ensure_site_name_active(db, study_id, precaution_in.site_name)
|
||||||
precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id)
|
precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="precaution",
|
|
||||||
entity_id=precaution.id,
|
|
||||||
action="CREATE_PRECAUTION",
|
|
||||||
detail=_precaution_audit_detail("创建", precaution),
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
|
||||||
)
|
|
||||||
return PrecautionRead.model_validate(precaution)
|
return PrecautionRead.model_validate(precaution)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,16 +98,6 @@ async def update_precaution(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||||
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
||||||
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in)
|
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in)
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="precaution",
|
|
||||||
entity_id=precaution_id,
|
|
||||||
action="UPDATE_PRECAUTION",
|
|
||||||
detail=_precaution_audit_detail("更新", precaution),
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
|
||||||
)
|
|
||||||
return PrecautionRead.model_validate(precaution)
|
return PrecautionRead.model_validate(precaution)
|
||||||
|
|
||||||
|
|
||||||
@@ -144,15 +117,4 @@ async def delete_precaution(
|
|||||||
if not precaution or precaution.study_id != study_id:
|
if not precaution or precaution.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||||
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
||||||
precaution_detail = _precaution_audit_detail("删除", precaution)
|
|
||||||
await precaution_crud.delete_precaution(db, precaution)
|
await precaution_crud.delete_precaution(db, precaution)
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="precaution",
|
|
||||||
entity_id=precaution_id,
|
|
||||||
action="DELETE_PRECAUTION",
|
|
||||||
detail=precaution_detail,
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice, collaboration
|
||||||
|
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
|
api_router.include_router(admin_email_settings.router, prefix="/admin", tags=["admin"])
|
||||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||||
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
||||||
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
|
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
|
||||||
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
|
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
|
||||||
|
api_router.include_router(desktop_notifications.router, prefix="/desktop-notifications", tags=["desktop-notifications"])
|
||||||
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
|
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
|
||||||
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
|
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
|
||||||
api_router.include_router(api_permissions.router, tags=["api-permissions"])
|
api_router.include_router(api_permissions.router, tags=["api-permissions"])
|
||||||
api_router.include_router(api_permissions.study_router, prefix="/studies/{study_id}", tags=["api-permissions"])
|
api_router.include_router(api_permissions.study_router, prefix="/studies/{study_id}", tags=["api-permissions"])
|
||||||
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
|
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
|
||||||
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
|
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
|
||||||
|
api_router.include_router(onlyoffice.router, prefix="/onlyoffice", tags=["onlyoffice"])
|
||||||
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
|
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
|
||||||
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
|
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
|
||||||
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
|
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
|
||||||
@@ -28,6 +30,8 @@ api_router.include_router(material_equipments.router, prefix="/studies/{study_id
|
|||||||
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
|
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
|
||||||
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
||||||
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
|
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
|
||||||
|
api_router.include_router(collaboration.router, prefix="/studies/{study_id}/collaboration", tags=["collaboration"])
|
||||||
|
api_router.include_router(collaboration.public_router, prefix="/collaboration/shares", tags=["collaboration-shares"])
|
||||||
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
|
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
|
||||||
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
||||||
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
|
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_db_session, is_system_admin, require_roles
|
from app.core.deps import get_db_session, is_system_admin, require_roles
|
||||||
@@ -8,7 +9,8 @@ from app.schemas.common import PaginatedResponse
|
|||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
from app.utils.pagination import paginate
|
from app.utils.pagination import paginate
|
||||||
from app.schemas.user import UserCreate, UserRead, UserStatus, UserUpdate
|
from app.schemas.user import LoginStatus, UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
|
||||||
|
from app.services.user_login_sessions import get_login_summaries, list_login_activities, login_activity_payload
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -19,12 +21,48 @@ async def list_users(
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
keyword: str | None = Query(default=None),
|
keyword: str | None = Query(default=None),
|
||||||
user_status: UserStatus | None = Query(default=None, alias="status"),
|
user_status: UserStatus | None = Query(default=None, alias="status"),
|
||||||
|
login_status: LoginStatus | None = Query(default=None),
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(require_roles(["ADMIN"])),
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
) -> PaginatedResponse[UserRead]:
|
) -> PaginatedResponse[UserRead]:
|
||||||
users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status)
|
users = await user_crud.list_users(
|
||||||
total_users = await user_crud.count_users(db, keyword=keyword, status=user_status)
|
db, skip=skip, limit=limit, keyword=keyword, status=user_status, login_status=login_status
|
||||||
return paginate(list(users), total=total_users)
|
)
|
||||||
|
total_users = await user_crud.count_users(
|
||||||
|
db, keyword=keyword, status=user_status, login_status=login_status
|
||||||
|
)
|
||||||
|
summaries = await get_login_summaries(db, [user.id for user in users])
|
||||||
|
items = []
|
||||||
|
for user in users:
|
||||||
|
summary = summaries.get(user.id)
|
||||||
|
item = UserRead.model_validate(user).model_dump()
|
||||||
|
if summary:
|
||||||
|
item.update(
|
||||||
|
{
|
||||||
|
"login_status": summary.status,
|
||||||
|
"last_login_at": summary.last_login_at,
|
||||||
|
"last_seen_at": summary.last_seen_at,
|
||||||
|
"last_client_type": summary.client_type,
|
||||||
|
"active_session_count": summary.active_session_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
items.append(item)
|
||||||
|
return paginate(items, total=total_users)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/login-activities", response_model=list[UserLoginActivityRead])
|
||||||
|
async def read_user_login_activities(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
response: Response,
|
||||||
|
limit: int = Query(default=30, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
|
) -> list[UserLoginActivityRead]:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
if not await user_crud.get_by_id(db, user_id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
activities = await list_login_activities(db, user_id=user_id, limit=limit)
|
||||||
|
return [login_activity_payload(activity) for activity in activities]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -97,4 +135,16 @@ async def delete_user(
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="该账号仍在项目成员中,请先在项目成员配置中移除后再删除",
|
detail="该账号仍在项目成员中,请先在项目成员配置中移除后再删除",
|
||||||
)
|
)
|
||||||
|
if await user_crud.user_has_retained_history(db, db_user.id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="该账号已有审计或权限访问记录,请停用账号以保留历史追溯",
|
||||||
|
)
|
||||||
|
try:
|
||||||
await user_crud.delete_user(db, db_user)
|
await user_crud.delete_user(db, db_user)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="该账号已有业务记录,请停用账号以保留历史追溯",
|
||||||
|
) from exc
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.crud import study as study_crud
|
|||||||
from app.crud import visit as visit_crud
|
from app.crud import visit as visit_crud
|
||||||
from app.schemas.subject import SubjectUpdate
|
from app.schemas.subject import SubjectUpdate
|
||||||
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate
|
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate
|
||||||
|
from app.services import notification_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -42,6 +43,17 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_visit_reminders(db: AsyncSession, visit_ids: list[uuid.UUID]) -> None:
|
||||||
|
for visit_id in set(visit_ids):
|
||||||
|
await notification_service.resolve_source_notifications(
|
||||||
|
db,
|
||||||
|
source_type="SUBJECT_VISIT_WINDOW",
|
||||||
|
source_id=str(visit_id),
|
||||||
|
)
|
||||||
|
if visit_ids:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/",
|
"/",
|
||||||
response_model=list[VisitRead],
|
response_model=list[VisitRead],
|
||||||
@@ -145,6 +157,8 @@ async def create_early_termination(
|
|||||||
if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
|
if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期")
|
||||||
|
|
||||||
|
existing_visit_ids = [visit.id for visit in await visit_crud.list_visits(db, subject_id)]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
visit = await visit_crud.create_early_termination_visit(
|
visit = await visit_crud.create_early_termination_visit(
|
||||||
db,
|
db,
|
||||||
@@ -164,6 +178,7 @@ async def create_early_termination(
|
|||||||
drop_reason=reason,
|
drop_reason=reason,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await _resolve_visit_reminders(db, existing_visit_ids)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -209,6 +224,8 @@ async def update_visit(
|
|||||||
old_status = visit.status
|
old_status = visit.status
|
||||||
updated = await visit_crud.update_visit(db, visit, visit_in)
|
updated = await visit_crud.update_visit(db, visit, visit_in)
|
||||||
await subject_crud.sync_subject_status(db, subject)
|
await subject_crud.sync_subject_status(db, subject)
|
||||||
|
if updated.actual_date is not None or updated.status in {"DONE", "CANCELLED"}:
|
||||||
|
await _resolve_visit_reminders(db, [updated.id])
|
||||||
detail = None
|
detail = None
|
||||||
if visit_in.status:
|
if visit_in.status:
|
||||||
detail = json.dumps(
|
detail = json.dumps(
|
||||||
@@ -253,6 +270,7 @@ async def delete_visit(
|
|||||||
visit_detail = _visit_audit_detail("删除", subject, visit)
|
visit_detail = _visit_audit_detail("删除", subject, visit)
|
||||||
await visit_crud.delete_visit(db, visit)
|
await visit_crud.delete_visit(db, visit)
|
||||||
await subject_crud.sync_subject_status(db, subject)
|
await subject_crud.sync_subject_status(db, subject)
|
||||||
|
await _resolve_visit_reminders(db, [visit_id])
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
|
|||||||
@@ -643,6 +643,43 @@ API_ENDPOINT_PERMISSIONS = {
|
|||||||
"description": "删除文档",
|
"description": "删除文档",
|
||||||
"default_roles": ["PM"],
|
"default_roles": ["PM"],
|
||||||
},
|
},
|
||||||
|
# 共享库在线协作(文档级邀请权限在接口权限之后继续校验)
|
||||||
|
"collaboration:create": {
|
||||||
|
"module": "shared_library",
|
||||||
|
"action": "write",
|
||||||
|
"description": "创建在线协作文件",
|
||||||
|
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||||
|
},
|
||||||
|
"collaboration:read": {
|
||||||
|
"module": "shared_library",
|
||||||
|
"action": "read",
|
||||||
|
"description": "查看在线协作文件",
|
||||||
|
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
|
||||||
|
},
|
||||||
|
"collaboration:edit": {
|
||||||
|
"module": "shared_library",
|
||||||
|
"action": "write",
|
||||||
|
"description": "编辑受邀在线协作文件",
|
||||||
|
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
|
||||||
|
},
|
||||||
|
"collaboration:manage": {
|
||||||
|
"module": "shared_library",
|
||||||
|
"action": "write",
|
||||||
|
"description": "管理在线协作文件成员与目录",
|
||||||
|
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||||
|
},
|
||||||
|
"collaboration:export": {
|
||||||
|
"module": "shared_library",
|
||||||
|
"action": "export",
|
||||||
|
"description": "另存为在线协作文件副本",
|
||||||
|
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||||
|
},
|
||||||
|
"collaboration:delete": {
|
||||||
|
"module": "shared_library",
|
||||||
|
"action": "write",
|
||||||
|
"description": "移入或恢复在线协作文件",
|
||||||
|
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _operation_read_candidates(operation_key: str) -> list[str]:
|
def _operation_read_candidates(operation_key: str) -> list[str]:
|
||||||
@@ -843,6 +880,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
|||||||
"precautions:read",
|
"precautions:read",
|
||||||
"faq:read",
|
"faq:read",
|
||||||
"faq_category:read",
|
"faq_category:read",
|
||||||
|
"collaboration:read",
|
||||||
],
|
],
|
||||||
"write": [
|
"write": [
|
||||||
"precautions:create",
|
"precautions:create",
|
||||||
@@ -858,6 +896,11 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
|||||||
"faq_reply:create",
|
"faq_reply:create",
|
||||||
"faq_reply:delete",
|
"faq_reply:delete",
|
||||||
"faq_attachments:delete",
|
"faq_attachments:delete",
|
||||||
|
"collaboration:create",
|
||||||
|
"collaboration:edit",
|
||||||
|
"collaboration:manage",
|
||||||
|
"collaboration:export",
|
||||||
|
"collaboration:delete",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
@@ -19,13 +20,45 @@ class Settings(BaseSettings):
|
|||||||
JWT_EXPIRE_MINUTES: int = 60
|
JWT_EXPIRE_MINUTES: int = 60
|
||||||
JWT_EXTEND_GRACE_SECONDS: int = 120
|
JWT_EXTEND_GRACE_SECONDS: int = 120
|
||||||
ABSOLUTE_SESSION_MAX_HOURS: int = 8
|
ABSOLUTE_SESSION_MAX_HOURS: int = 8
|
||||||
|
DESKTOP_SESSION_MAX_DAYS: int = 30
|
||||||
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
|
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
|
||||||
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
|
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
|
||||||
LOGIN_RSA_KEY_ID: str = "default"
|
LOGIN_RSA_KEY_ID: str = "default"
|
||||||
LOGIN_CHALLENGE_TTL_SECONDS: int = 120
|
LOGIN_CHALLENGE_TTL_SECONDS: int = 120
|
||||||
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
|
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
|
||||||
|
SETTINGS_ENCRYPTION_KEY: Optional[str] = None
|
||||||
|
FRONTEND_PUBLIC_URL: str = "http://localhost:8888"
|
||||||
|
CORS_ALLOWED_ORIGINS: str = (
|
||||||
|
"http://localhost:8888,http://localhost:5173,"
|
||||||
|
"tauri://localhost,http://tauri.localhost"
|
||||||
|
)
|
||||||
IP2REGION_XDB_PATH: Optional[str] = None
|
IP2REGION_XDB_PATH: Optional[str] = None
|
||||||
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
|
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
|
||||||
|
TRUSTED_PROXY_CIDRS: str = "127.0.0.1/32,::1/128,172.16.0.0/12"
|
||||||
|
MONITORING_SERVER_PUBLIC_IP: Optional[str] = None
|
||||||
|
MONITORING_PUBLIC_IP_DISCOVERY_URLS: str = (
|
||||||
|
"https://api64.ipify.org,https://icanhazip.com"
|
||||||
|
)
|
||||||
|
MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
|
||||||
|
MONITORING_SERVER_LOCATION_CACHE_SECONDS: int = Field(default=86400, ge=300, le=604800)
|
||||||
|
MONITORING_IP_GEO_FALLBACK_ENABLED: bool = True
|
||||||
|
MONITORING_IP_GEO_FALLBACK_API_KEY: Optional[str] = None
|
||||||
|
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
|
||||||
|
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: int = Field(default=604800, ge=3600, le=2592000)
|
||||||
|
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: int = Field(default=10, ge=1, le=100)
|
||||||
|
MONITORING_ACCESS_LOG_RETENTION_DAYS: int = Field(default=90, ge=7, le=3650)
|
||||||
|
MONITORING_METRIC_RETENTION_DAYS: int = Field(default=400, ge=30, le=3650)
|
||||||
|
MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800)
|
||||||
|
USER_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650)
|
||||||
|
USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600)
|
||||||
|
NOTIFICATION_SYNC_INTERVAL_SECONDS: int = Field(default=300, ge=60, le=3600)
|
||||||
|
ONLYOFFICE_ENABLED: bool = False
|
||||||
|
ONLYOFFICE_JWT_SECRET: Optional[str] = None
|
||||||
|
ONLYOFFICE_INTERNAL_URL: str = "http://onlyoffice"
|
||||||
|
ONLYOFFICE_STORAGE_BASE_URL: str = "http://backend:8000"
|
||||||
|
ONLYOFFICE_INSTANCE_ID: Optional[str] = None
|
||||||
|
ONLYOFFICE_CONFIG_TTL_SECONDS: int = Field(default=300, ge=60, le=900)
|
||||||
|
COLLABORATION_MAX_FILE_BYTES: int = Field(default=50 * 1024 * 1024, ge=1024, le=500 * 1024 * 1024)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
@@ -34,3 +67,35 @@ def get_settings() -> Settings:
|
|||||||
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_onlyoffice_configuration() -> None:
|
||||||
|
if not settings.ONLYOFFICE_ENABLED:
|
||||||
|
return
|
||||||
|
secret = (settings.ONLYOFFICE_JWT_SECRET or "").strip()
|
||||||
|
instance_id = (settings.ONLYOFFICE_INSTANCE_ID or "").strip()
|
||||||
|
if secret == settings.JWT_SECRET_KEY:
|
||||||
|
raise RuntimeError("ONLYOFFICE_JWT_SECRET must not reuse JWT_SECRET_KEY")
|
||||||
|
if len(secret) < 32:
|
||||||
|
raise RuntimeError("ONLYOFFICE_JWT_SECRET must contain at least 32 characters when ONLYOFFICE is enabled")
|
||||||
|
if not instance_id:
|
||||||
|
raise RuntimeError("ONLYOFFICE_INSTANCE_ID is required when ONLYOFFICE is enabled")
|
||||||
|
for name, value in (
|
||||||
|
("ONLYOFFICE_INTERNAL_URL", settings.ONLYOFFICE_INTERNAL_URL),
|
||||||
|
("ONLYOFFICE_STORAGE_BASE_URL", settings.ONLYOFFICE_STORAGE_BASE_URL),
|
||||||
|
):
|
||||||
|
parsed = urlsplit(value.strip())
|
||||||
|
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
|
||||||
|
raise RuntimeError(f"{name} must be an HTTP(S) URL")
|
||||||
|
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||||
|
raise RuntimeError(f"{name} must not contain credentials, a query, or a fragment")
|
||||||
|
if parsed.path not in {"", "/"}:
|
||||||
|
raise RuntimeError(f"{name} must not contain a path")
|
||||||
|
|
||||||
|
|
||||||
|
def get_cors_allowed_origins() -> list[str]:
|
||||||
|
return [
|
||||||
|
origin.strip()
|
||||||
|
for origin in settings.CORS_ALLOWED_ORIGINS.split(",")
|
||||||
|
if origin.strip()
|
||||||
|
]
|
||||||
|
|||||||
@@ -286,8 +286,9 @@ def _enqueue_permission_log(
|
|||||||
|
|
||||||
writer = get_log_writer()
|
writer = get_log_writer()
|
||||||
if writer:
|
if writer:
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
from app.core.request_context import build_request_audit_context, get_request_audit_context
|
||||||
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None)
|
|
||||||
|
context = get_request_audit_context() or build_request_audit_context(request)
|
||||||
writer.enqueue({
|
writer.enqueue({
|
||||||
"study_id": study_id,
|
"study_id": study_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -295,7 +296,16 @@ def _enqueue_permission_log(
|
|||||||
"role": role,
|
"role": role,
|
||||||
"allowed": allowed,
|
"allowed": allowed,
|
||||||
"elapsed_ms": elapsed_ms,
|
"elapsed_ms": elapsed_ms,
|
||||||
"ip_address": ip,
|
"ip_address": context.client_ip,
|
||||||
|
"user_agent": context.user_agent,
|
||||||
|
"client_type": context.client_type,
|
||||||
|
"client_version": context.client_version,
|
||||||
|
"client_platform": context.client_platform,
|
||||||
|
"build_channel": context.build_channel,
|
||||||
|
"build_commit": context.build_commit,
|
||||||
|
"request_headers": context.request_headers,
|
||||||
|
"request_snapshot": context.request_snapshot,
|
||||||
|
"request_id": context.request_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ class PermissionCache:
|
|||||||
if cache_key in self._member_role_cache:
|
if cache_key in self._member_role_cache:
|
||||||
cached_role, timestamp = self._member_role_cache[cache_key]
|
cached_role, timestamp = self._member_role_cache[cache_key]
|
||||||
if not self._is_expired(timestamp, ttl):
|
if not self._is_expired(timestamp, ttl):
|
||||||
|
from app.core.permission_monitor import get_permission_monitor
|
||||||
|
get_permission_monitor().record_cache_hit("member_roles")
|
||||||
return cached_role
|
return cached_role
|
||||||
|
|
||||||
|
from app.core.permission_monitor import get_permission_monitor
|
||||||
|
get_permission_monitor().record_cache_miss("member_roles")
|
||||||
membership = await member_crud.get_member(db, study_id, user_id)
|
membership = await member_crud.get_member(db, study_id, user_id)
|
||||||
role = membership.role_in_study if membership and membership.is_active else None
|
role = membership.role_in_study if membership and membership.is_active else None
|
||||||
|
|
||||||
@@ -102,6 +106,17 @@ class PermissionCache:
|
|||||||
self._member_role_cache.clear()
|
self._member_role_cache.clear()
|
||||||
|
|
||||||
def get_cache_stats(self) -> dict[str, Any]:
|
def get_cache_stats(self) -> dict[str, Any]:
|
||||||
|
now = time.time()
|
||||||
|
self._project_permissions_cache = {
|
||||||
|
key: value
|
||||||
|
for key, value in self._project_permissions_cache.items()
|
||||||
|
if now - value[1] <= self.default_ttl
|
||||||
|
}
|
||||||
|
self._member_role_cache = {
|
||||||
|
key: value
|
||||||
|
for key, value in self._member_role_cache.items()
|
||||||
|
if now - value[1] <= self.default_ttl
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"project_permissions_count": len(self._project_permissions_cache),
|
"project_permissions_count": len(self._project_permissions_cache),
|
||||||
"member_role_count": len(self._member_role_cache),
|
"member_role_count": len(self._member_role_cache),
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ permission_access_logs 表中,通过 /metrics 端点实时聚合查询。
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.core.permission_cache import get_permission_cache
|
from app.core.permission_cache import get_permission_cache
|
||||||
|
|
||||||
|
|
||||||
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 10
|
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 50
|
||||||
|
CACHE_METRICS_WINDOW_SECONDS = 3600
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -25,6 +27,11 @@ class CacheMetrics:
|
|||||||
cache_hits: int = 0
|
cache_hits: int = 0
|
||||||
cache_misses: int = 0
|
cache_misses: int = 0
|
||||||
cache_invalidations: int = 0
|
cache_invalidations: int = 0
|
||||||
|
_access_events: deque[tuple[float, str, bool]] = field(
|
||||||
|
default_factory=deque,
|
||||||
|
repr=False,
|
||||||
|
)
|
||||||
|
_invalidation_events: deque[float] = field(default_factory=deque, repr=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hit_rate(self) -> float:
|
def hit_rate(self) -> float:
|
||||||
@@ -48,6 +55,53 @@ class CacheMetrics:
|
|||||||
"miss_rate": round(self.miss_rate, 2),
|
"miss_rate": round(self.miss_rate, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def record_access(self, cache_name: str, *, hit: bool) -> None:
|
||||||
|
self.total_accesses += 1
|
||||||
|
if hit:
|
||||||
|
self.cache_hits += 1
|
||||||
|
else:
|
||||||
|
self.cache_misses += 1
|
||||||
|
self._access_events.append((time.time(), cache_name, hit))
|
||||||
|
|
||||||
|
def record_invalidation(self) -> None:
|
||||||
|
self.cache_invalidations += 1
|
||||||
|
self._invalidation_events.append(time.time())
|
||||||
|
|
||||||
|
def window_stats(self, seconds: int = CACHE_METRICS_WINDOW_SECONDS) -> dict[str, Any]:
|
||||||
|
cutoff = time.time() - seconds
|
||||||
|
while self._access_events and self._access_events[0][0] < cutoff:
|
||||||
|
self._access_events.popleft()
|
||||||
|
while self._invalidation_events and self._invalidation_events[0] < cutoff:
|
||||||
|
self._invalidation_events.popleft()
|
||||||
|
|
||||||
|
def summarize(cache_name: str | None = None) -> dict[str, Any]:
|
||||||
|
events = [
|
||||||
|
event for event in self._access_events
|
||||||
|
if cache_name is None or event[1] == cache_name
|
||||||
|
]
|
||||||
|
hits = sum(event[2] for event in events)
|
||||||
|
total = len(events)
|
||||||
|
misses = total - hits
|
||||||
|
return {
|
||||||
|
"total_accesses": total,
|
||||||
|
"cache_hits": hits,
|
||||||
|
"cache_misses": misses,
|
||||||
|
"cache_invalidations": len(self._invalidation_events),
|
||||||
|
"hit_rate": round(hits / total * 100, 2) if total else 0.0,
|
||||||
|
"miss_rate": round(misses / total * 100, 2) if total else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = summarize()
|
||||||
|
stats.update({
|
||||||
|
"window_seconds": seconds,
|
||||||
|
"scope": "sliding_window",
|
||||||
|
"by_cache": {
|
||||||
|
"project_permissions": summarize("project_permissions"),
|
||||||
|
"member_roles": summarize("member_roles"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PermissionSystemMetrics:
|
class PermissionSystemMetrics:
|
||||||
@@ -70,16 +124,14 @@ class PermissionMonitor:
|
|||||||
self.metrics = PermissionSystemMetrics()
|
self.metrics = PermissionSystemMetrics()
|
||||||
self._alerts: list[dict[str, Any]] = []
|
self._alerts: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def record_cache_hit(self) -> None:
|
def record_cache_hit(self, cache_name: str = "project_permissions") -> None:
|
||||||
self.metrics.cache_metrics.total_accesses += 1
|
self.metrics.cache_metrics.record_access(cache_name, hit=True)
|
||||||
self.metrics.cache_metrics.cache_hits += 1
|
|
||||||
|
|
||||||
def record_cache_miss(self) -> None:
|
def record_cache_miss(self, cache_name: str = "project_permissions") -> None:
|
||||||
self.metrics.cache_metrics.total_accesses += 1
|
self.metrics.cache_metrics.record_access(cache_name, hit=False)
|
||||||
self.metrics.cache_metrics.cache_misses += 1
|
|
||||||
|
|
||||||
def record_cache_invalidation(self) -> None:
|
def record_cache_invalidation(self) -> None:
|
||||||
self.metrics.cache_metrics.cache_invalidations += 1
|
self.metrics.cache_metrics.record_invalidation()
|
||||||
|
|
||||||
def record_error_alert(self, error: Exception) -> None:
|
def record_error_alert(self, error: Exception) -> None:
|
||||||
self._add_alert(
|
self._add_alert(
|
||||||
@@ -119,12 +171,14 @@ class PermissionMonitor:
|
|||||||
cache = get_permission_cache()
|
cache = get_permission_cache()
|
||||||
return {
|
return {
|
||||||
"cache_items": cache.get_cache_stats(),
|
"cache_items": cache.get_cache_stats(),
|
||||||
"cache_metrics": self.metrics.cache_metrics.to_dict(),
|
"cache_metrics": self.metrics.cache_metrics.window_stats(),
|
||||||
|
"cache_metrics_lifetime": self.metrics.cache_metrics.to_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_metrics(self) -> dict[str, Any]:
|
def get_metrics(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"cache_metrics": self.metrics.cache_metrics.to_dict(),
|
"cache_metrics": self.metrics.cache_metrics.window_stats(),
|
||||||
|
"cache_metrics_lifetime": self.metrics.cache_metrics.to_dict(),
|
||||||
"uptime_seconds": self.metrics.uptime_seconds,
|
"uptime_seconds": self.metrics.uptime_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Request-scoped metadata used by server-side audit writers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RequestAuditContext:
|
||||||
|
request_id: str | None = None
|
||||||
|
client_ip: str | None = None
|
||||||
|
user_agent: str | None = None
|
||||||
|
client_type: str | None = None
|
||||||
|
client_version: str | None = None
|
||||||
|
client_platform: str | None = None
|
||||||
|
build_channel: str | None = None
|
||||||
|
build_commit: str | None = None
|
||||||
|
request_headers: dict[str, str] | None = None
|
||||||
|
request_snapshot: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
|
||||||
|
"request_audit_context",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SENSITIVE_TEXT_PATTERN = re.compile(
|
||||||
|
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization|password|passwd|secret|credential|api[-_]?key|session)=([^&\s]+)"
|
||||||
|
)
|
||||||
|
_SENSITIVE_HEADER_NAME_PATTERN = re.compile(
|
||||||
|
r"(?i)(authorization|cookie|set-cookie|token|password|passwd|secret|credential|api[-_]?key|session)"
|
||||||
|
)
|
||||||
|
_SAFE_REQUEST_HEADER_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"accept",
|
||||||
|
"accept-language",
|
||||||
|
"content-type",
|
||||||
|
"host",
|
||||||
|
"origin",
|
||||||
|
"user-agent",
|
||||||
|
"x-request-id",
|
||||||
|
"x-correlation-id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_SAFE_REQUEST_HEADER_PREFIXES = ("x-ctms-",)
|
||||||
|
_SENSITIVE_PARAMETER_NAME_PATTERN = re.compile(
|
||||||
|
r"(?i)(token|authorization|password|passwd|secret|credential|api[-_]?key|session|"
|
||||||
|
r"subject|participant|patient|user_?name|full_?name|email|phone|mobile|id_?card|"
|
||||||
|
r"identity|certificate|contact|address)"
|
||||||
|
)
|
||||||
|
_KNOWN_CLIENT_TYPES = frozenset({"web", "desktop"})
|
||||||
|
_CTMS_CLIENT_SOURCE_TO_TYPE = {
|
||||||
|
"ctms-web": "web",
|
||||||
|
"ctms-desktop": "desktop",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_audit_value(value: str | None, max_length: int) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip())
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
return cleaned[:max_length]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_header_value(value: str | None, max_length: int) -> str | None:
|
||||||
|
return _clean_audit_value(value, max_length)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ctms_client_type(headers: Any) -> str | None:
|
||||||
|
source = _clean_header_value(headers.get("x-ctms-client-source"), 32)
|
||||||
|
if source:
|
||||||
|
mapped_type = _CTMS_CLIENT_SOURCE_TO_TYPE.get(source.strip().lower())
|
||||||
|
if mapped_type:
|
||||||
|
return mapped_type
|
||||||
|
|
||||||
|
client_type = _clean_header_value(headers.get("x-ctms-client-type"), 16)
|
||||||
|
if not client_type:
|
||||||
|
return None
|
||||||
|
normalized = client_type.strip().lower()
|
||||||
|
return normalized if normalized in _KNOWN_CLIENT_TYPES else normalized[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def build_sanitized_request_headers(request: Any) -> dict[str, str] | None:
|
||||||
|
captured: dict[str, str] = {}
|
||||||
|
for raw_name, raw_value in request.headers.items():
|
||||||
|
name = str(raw_name).strip().lower()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if _SENSITIVE_HEADER_NAME_PATTERN.search(name):
|
||||||
|
captured[name] = "[redacted]"
|
||||||
|
continue
|
||||||
|
if name not in _SAFE_REQUEST_HEADER_NAMES and not name.startswith(_SAFE_REQUEST_HEADER_PREFIXES):
|
||||||
|
continue
|
||||||
|
value = _clean_header_value(str(raw_value), 500)
|
||||||
|
if value:
|
||||||
|
captured[name] = value
|
||||||
|
return captured or None
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_named_value(raw_name: Any, raw_value: Any, max_length: int = 500) -> dict[str, str] | None:
|
||||||
|
name = _clean_audit_value(str(raw_name), 120)
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
if _SENSITIVE_PARAMETER_NAME_PATTERN.search(name):
|
||||||
|
return {"name": name, "value": "[redacted]"}
|
||||||
|
value = _clean_audit_value(str(raw_value), max_length)
|
||||||
|
if value is None:
|
||||||
|
return {"name": name, "value": ""}
|
||||||
|
return {"name": name, "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_param_items(request: Any) -> list[dict[str, str]] | None:
|
||||||
|
query_params = getattr(request, "query_params", None)
|
||||||
|
if not query_params:
|
||||||
|
return None
|
||||||
|
if hasattr(query_params, "multi_items"):
|
||||||
|
raw_items = query_params.multi_items()
|
||||||
|
else:
|
||||||
|
raw_items = query_params.items()
|
||||||
|
items = [
|
||||||
|
item
|
||||||
|
for item in (_sanitize_named_value(name, value) for name, value in raw_items)
|
||||||
|
if item is not None
|
||||||
|
][:50]
|
||||||
|
return items or None
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitized_query_string(items: list[dict[str, str]] | None) -> str | None:
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
value = "&".join(f"{item['name']}={item['value']}" for item in items)
|
||||||
|
return value[:2000] or None
|
||||||
|
|
||||||
|
|
||||||
|
def _request_client_snapshot(request: Any) -> dict[str, str | int | None] | None:
|
||||||
|
client = getattr(request, "client", None)
|
||||||
|
if not client:
|
||||||
|
return None
|
||||||
|
host = _clean_audit_value(getattr(client, "host", None), 120)
|
||||||
|
port = getattr(client, "port", None)
|
||||||
|
if host is None and port is None:
|
||||||
|
return None
|
||||||
|
return {"host": host, "port": port}
|
||||||
|
|
||||||
|
|
||||||
|
def build_request_snapshot(request: Any, *, request_id: str | None = None) -> dict[str, Any]:
|
||||||
|
headers = getattr(request, "headers", {})
|
||||||
|
scope = getattr(request, "scope", {}) or {}
|
||||||
|
url = getattr(request, "url", None)
|
||||||
|
query_params = _query_param_items(request)
|
||||||
|
path = getattr(url, "path", None) or scope.get("path")
|
||||||
|
method = getattr(request, "method", None) or scope.get("method")
|
||||||
|
snapshot: dict[str, Any] = {
|
||||||
|
"request_id": request_id,
|
||||||
|
"method": _clean_audit_value(str(method).upper() if method else None, 12),
|
||||||
|
"path": _clean_audit_value(path, 500),
|
||||||
|
"query_string": _sanitized_query_string(query_params),
|
||||||
|
"query_params": query_params,
|
||||||
|
"headers": build_sanitized_request_headers(request),
|
||||||
|
"http_version": _clean_audit_value(scope.get("http_version"), 16),
|
||||||
|
"scheme": _clean_audit_value(getattr(url, "scheme", None) or scope.get("scheme"), 16),
|
||||||
|
"client": _request_client_snapshot(request),
|
||||||
|
"content": {
|
||||||
|
"type": _clean_audit_value(headers.get("content-type"), 200),
|
||||||
|
"length": _clean_audit_value(headers.get("content-length"), 32),
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"captured": False,
|
||||||
|
"reason": "body_not_captured_by_audit_policy",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {key: value for key, value in snapshot.items() if value not in (None, {}, [])}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ip(value: str | None):
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address((value or "").strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _trusted_proxy_networks():
|
||||||
|
networks = []
|
||||||
|
for value in settings.TRUSTED_PROXY_CIDRS.split(","):
|
||||||
|
candidate = value.strip()
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
networks.append(ipaddress.ip_network(candidate, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return tuple(networks)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trusted_proxy(value: str | None) -> bool:
|
||||||
|
address = _parse_ip(value)
|
||||||
|
return bool(address and any(address in network for network in _trusted_proxy_networks()))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_client_ip(request: Any) -> str | None:
|
||||||
|
peer_ip = request.client.host if request.client else None
|
||||||
|
if not _is_trusted_proxy(peer_ip):
|
||||||
|
return _clean_header_value(peer_ip, 45)
|
||||||
|
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
chain = [part.strip() for part in forwarded.split(",") if _parse_ip(part)]
|
||||||
|
chain.append(str(peer_ip))
|
||||||
|
for candidate in reversed(chain):
|
||||||
|
if not _is_trusted_proxy(candidate):
|
||||||
|
return _clean_header_value(candidate, 45)
|
||||||
|
|
||||||
|
real_ip = request.headers.get("x-real-ip")
|
||||||
|
if _parse_ip(real_ip):
|
||||||
|
return _clean_header_value(real_ip.strip(), 45)
|
||||||
|
return _clean_header_value(peer_ip, 45)
|
||||||
|
|
||||||
|
|
||||||
|
def build_request_audit_context(request: Any) -> RequestAuditContext:
|
||||||
|
headers = request.headers
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
return RequestAuditContext(
|
||||||
|
request_id=request_id,
|
||||||
|
client_ip=resolve_client_ip(request),
|
||||||
|
user_agent=_clean_header_value(headers.get("user-agent"), 500),
|
||||||
|
client_type=resolve_ctms_client_type(headers),
|
||||||
|
client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32),
|
||||||
|
client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16),
|
||||||
|
build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16),
|
||||||
|
build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64),
|
||||||
|
request_headers=build_sanitized_request_headers(request),
|
||||||
|
request_snapshot=build_request_snapshot(request, request_id=request_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_request_audit_context(context: RequestAuditContext) -> Token[RequestAuditContext | None]:
|
||||||
|
return _request_audit_context.set(context)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_request_audit_context(token: Token[RequestAuditContext | None]) -> None:
|
||||||
|
_request_audit_context.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_audit_context() -> RequestAuditContext | None:
|
||||||
|
return _request_audit_context.get()
|
||||||
@@ -19,16 +19,32 @@ def create_access_token(
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
expires_minutes: Optional[int] = None,
|
expires_minutes: Optional[int] = None,
|
||||||
session_start: Optional[datetime] = None,
|
session_start: Optional[datetime] = None,
|
||||||
|
max_age_seconds: Optional[int] = None,
|
||||||
|
issued_at: Optional[datetime] = None,
|
||||||
|
client_type: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
now = datetime.now(timezone.utc)
|
now = issued_at or datetime.now(timezone.utc)
|
||||||
|
if now.tzinfo is None:
|
||||||
|
now = now.replace(tzinfo=timezone.utc)
|
||||||
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
|
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
|
||||||
session_start_time = session_start or now
|
session_start_time = session_start or now
|
||||||
|
if session_start_time.tzinfo is None:
|
||||||
|
session_start_time = session_start_time.replace(tzinfo=timezone.utc)
|
||||||
|
if max_age_seconds is not None:
|
||||||
|
session_expire = session_start_time + timedelta(seconds=max_age_seconds)
|
||||||
|
if expire > session_expire:
|
||||||
|
expire = session_expire
|
||||||
to_encode: Dict[str, Any] = {
|
to_encode: Dict[str, Any] = {
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"iat": int(now.timestamp()),
|
"iat": int(now.timestamp()),
|
||||||
"orig_iat": int(session_start_time.timestamp()),
|
"orig_iat": int(session_start_time.timestamp()),
|
||||||
}
|
}
|
||||||
|
if client_type:
|
||||||
|
to_encode["client_type"] = client_type
|
||||||
|
if session_id:
|
||||||
|
to_encode["sid"] = session_id
|
||||||
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
|
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import uuid
|
|||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.request_context import get_request_audit_context
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +21,16 @@ async def log_action(
|
|||||||
detail: str | None,
|
detail: str | None,
|
||||||
operator_id: uuid.UUID,
|
operator_id: uuid.UUID,
|
||||||
operator_role: str,
|
operator_role: str,
|
||||||
|
client_ip: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
client_type: str | None = None,
|
||||||
|
client_version: str | None = None,
|
||||||
|
client_platform: str | None = None,
|
||||||
|
build_channel: str | None = None,
|
||||||
|
build_commit: str | None = None,
|
||||||
auto_commit: bool = True,
|
auto_commit: bool = True,
|
||||||
) -> AuditLog:
|
) -> AuditLog:
|
||||||
|
request_context = get_request_audit_context()
|
||||||
log = AuditLog(
|
log = AuditLog(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
entity_type=entity_type,
|
entity_type=entity_type,
|
||||||
@@ -29,6 +39,13 @@ async def log_action(
|
|||||||
detail=detail,
|
detail=detail,
|
||||||
operator_id=operator_id,
|
operator_id=operator_id,
|
||||||
operator_role=operator_role,
|
operator_role=operator_role,
|
||||||
|
client_ip=client_ip if client_ip is not None else (request_context.client_ip if request_context else None),
|
||||||
|
user_agent=user_agent if user_agent is not None else (request_context.user_agent if request_context else None),
|
||||||
|
client_type=client_type if client_type is not None else (request_context.client_type if request_context else None),
|
||||||
|
client_version=client_version if client_version is not None else (request_context.client_version if request_context else None),
|
||||||
|
client_platform=client_platform if client_platform is not None else (request_context.client_platform if request_context else None),
|
||||||
|
build_channel=build_channel if build_channel is not None else (request_context.build_channel if request_context else None),
|
||||||
|
build_commit=build_commit if build_commit is not None else (request_context.build_commit if request_context else None),
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
if auto_commit:
|
if auto_commit:
|
||||||
@@ -47,6 +64,10 @@ async def list_logs(
|
|||||||
entity_id: uuid.UUID | None = None,
|
entity_id: uuid.UUID | None = None,
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
operator_id: uuid.UUID | None = None,
|
operator_id: uuid.UUID | None = None,
|
||||||
|
client_ip: str | None = None,
|
||||||
|
client_type: str | None = None,
|
||||||
|
start_time=None,
|
||||||
|
end_time=None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> Sequence[AuditLog]:
|
) -> Sequence[AuditLog]:
|
||||||
@@ -59,6 +80,24 @@ async def list_logs(
|
|||||||
stmt = stmt.where(AuditLog.action == action)
|
stmt = stmt.where(AuditLog.action == action)
|
||||||
if operator_id:
|
if operator_id:
|
||||||
stmt = stmt.where(AuditLog.operator_id == operator_id)
|
stmt = stmt.where(AuditLog.operator_id == operator_id)
|
||||||
|
if client_ip:
|
||||||
|
stmt = stmt.where(AuditLog.client_ip.ilike(f"%{client_ip.strip()}%"))
|
||||||
|
if client_type:
|
||||||
|
normalized_client_type = client_type.strip().lower()
|
||||||
|
if normalized_client_type == "unknown":
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
AuditLog.client_type.is_(None),
|
||||||
|
AuditLog.client_type == "",
|
||||||
|
~AuditLog.client_type.in_(("web", "desktop")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(AuditLog.client_type == normalized_client_type)
|
||||||
|
if start_time:
|
||||||
|
stmt = stmt.where(AuditLog.created_at >= start_time)
|
||||||
|
if end_time:
|
||||||
|
stmt = stmt.where(AuditLog.created_at <= end_time)
|
||||||
stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit)
|
stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|||||||
+49
-28
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Literal, Sequence
|
||||||
|
|
||||||
from sqlalchemy import delete, func, or_, select, update
|
from sqlalchemy import delete, func, or_, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -11,10 +12,14 @@ from app.core.config import (
|
|||||||
PROTECTED_ADMIN_DEFAULT_PASSWORD,
|
PROTECTED_ADMIN_DEFAULT_PASSWORD,
|
||||||
PROTECTED_ADMIN_EMAIL,
|
PROTECTED_ADMIN_EMAIL,
|
||||||
PROTECTED_ADMIN_FULL_NAME,
|
PROTECTED_ADMIN_FULL_NAME,
|
||||||
|
settings,
|
||||||
)
|
)
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.permission_access_log import PermissionAccessLog
|
||||||
from app.models.study_member import StudyMember
|
from app.models.study_member import StudyMember
|
||||||
from app.models.user import User, UserStatus
|
from app.models.user import User, UserStatus
|
||||||
|
from app.models.user_login_session import UserLoginSession
|
||||||
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
|
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
|
||||||
|
|
||||||
|
|
||||||
@@ -53,8 +58,8 @@ async def create_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) -> User:
|
async def create_registered_user(db: AsyncSession, user_in: UserRegisterRequest) -> User:
|
||||||
return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.PENDING)
|
return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.ACTIVE)
|
||||||
|
|
||||||
|
|
||||||
async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
|
async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
|
||||||
@@ -79,7 +84,13 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus | None = None):
|
def _apply_user_filters(
|
||||||
|
query,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: UserStatus | None = None,
|
||||||
|
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
|
||||||
|
):
|
||||||
if keyword:
|
if keyword:
|
||||||
pattern = f"%{keyword.strip()}%"
|
pattern = f"%{keyword.strip()}%"
|
||||||
query = query.where(
|
query = query.where(
|
||||||
@@ -91,6 +102,18 @@ def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus
|
|||||||
)
|
)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
query = query.where(User.status == status)
|
query = query.where(User.status == status)
|
||||||
|
if login_status is not None:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS)
|
||||||
|
has_online_session = (
|
||||||
|
select(UserLoginSession.id)
|
||||||
|
.where(
|
||||||
|
UserLoginSession.user_id == User.id,
|
||||||
|
UserLoginSession.ended_at.is_(None),
|
||||||
|
UserLoginSession.last_seen_at >= cutoff,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
query = query.where(has_online_session if login_status == "ONLINE" else ~has_online_session)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
@@ -101,8 +124,9 @@ async def list_users(
|
|||||||
*,
|
*,
|
||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
status: UserStatus | None = None,
|
status: UserStatus | None = None,
|
||||||
|
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
|
||||||
) -> Sequence[User]:
|
) -> Sequence[User]:
|
||||||
query = _apply_user_filters(select(User), keyword=keyword, status=status)
|
query = _apply_user_filters(select(User), keyword=keyword, status=status, login_status=login_status)
|
||||||
result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit))
|
result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit))
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -112,8 +136,14 @@ async def count_users(
|
|||||||
*,
|
*,
|
||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
status: UserStatus | None = None,
|
status: UserStatus | None = None,
|
||||||
|
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
query = _apply_user_filters(select(func.count()).select_from(User), keyword=keyword, status=status)
|
query = _apply_user_filters(
|
||||||
|
select(func.count()).select_from(User),
|
||||||
|
keyword=keyword,
|
||||||
|
status=status,
|
||||||
|
login_status=login_status,
|
||||||
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return int(result.scalar_one() or 0)
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
@@ -150,6 +180,19 @@ async def count_active_admins(db: AsyncSession) -> int:
|
|||||||
return int(result.scalar_one() or 0)
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def user_has_retained_history(db: AsyncSession, user_id: uuid.UUID) -> bool:
|
||||||
|
audit_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(AuditLog).where(AuditLog.operator_id == user_id)
|
||||||
|
)
|
||||||
|
if int(audit_count or 0) > 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
permission_log_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(PermissionAccessLog).where(PermissionAccessLog.user_id == user_id)
|
||||||
|
)
|
||||||
|
return int(permission_log_count or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
async def list_users_by_status(
|
async def list_users_by_status(
|
||||||
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
|
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
|
||||||
) -> Sequence[User]:
|
) -> Sequence[User]:
|
||||||
@@ -189,25 +232,3 @@ async def delete_user(db: AsyncSession, user: User) -> None:
|
|||||||
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
|
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
|
||||||
await db.delete(user)
|
await db.delete(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def approve_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
|
|
||||||
await db.execute(
|
|
||||||
update(User)
|
|
||||||
.where(User.id == user.id)
|
|
||||||
.values(status=UserStatus.ACTIVE, approved_by=admin_id, approved_at=func.now())
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def reject_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
|
|
||||||
await db.execute(
|
|
||||||
update(User)
|
|
||||||
.where(User.id == user.id)
|
|
||||||
.values(status=UserStatus.REJECTED, approved_by=admin_id, approved_at=func.now())
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ from app.models.audit_log import AuditLog # noqa: F401
|
|||||||
from app.models.etmf import EtmfNode # noqa: F401
|
from app.models.etmf import EtmfNode # noqa: F401
|
||||||
from app.models.document import Document # noqa: F401
|
from app.models.document import Document # noqa: F401
|
||||||
from app.models.document_version import DocumentVersion # noqa: F401
|
from app.models.document_version import DocumentVersion # noqa: F401
|
||||||
|
from app.models.collaboration import ( # noqa: F401
|
||||||
|
CollaborationCallbackReceipt,
|
||||||
|
CollaborationEditRequest,
|
||||||
|
CollaborationFile,
|
||||||
|
CollaborationFolder,
|
||||||
|
CollaborationMember,
|
||||||
|
CollaborationRevision,
|
||||||
|
CollaborationSession,
|
||||||
|
CollaborationShareLink,
|
||||||
|
)
|
||||||
from app.models.distribution import Distribution # noqa: F401
|
from app.models.distribution import Distribution # noqa: F401
|
||||||
from app.models.acknowledgement import Acknowledgement # noqa: F401
|
from app.models.acknowledgement import Acknowledgement # noqa: F401
|
||||||
from app.models.milestone import Milestone # noqa: F401
|
from app.models.milestone import Milestone # noqa: F401
|
||||||
@@ -42,3 +52,11 @@ from app.models.permission_access_log import PermissionAccessLog # noqa: F401
|
|||||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
|
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
|
||||||
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401
|
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401
|
||||||
from app.models.security_access_log import SecurityAccessLog # noqa: F401
|
from app.models.security_access_log import SecurityAccessLog # noqa: F401
|
||||||
|
from app.models.source_location_snapshot import SourceLocationSnapshot # noqa: F401
|
||||||
|
from app.models.user_login_session import UserLoginSession # noqa: F401
|
||||||
|
from app.models.desktop_notification import ( # noqa: F401
|
||||||
|
DesktopNotificationDelivery,
|
||||||
|
DesktopNotificationSubscription,
|
||||||
|
)
|
||||||
|
from app.models.notification import Notification # noqa: F401
|
||||||
|
from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401
|
||||||
|
|||||||
+95
-16
@@ -5,26 +5,44 @@ import time
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.core.config import settings
|
from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_router
|
||||||
|
from app.api.v1.collaboration import internal_router as collaboration_internal_router
|
||||||
|
from app.core.config import get_cors_allowed_origins, settings, validate_onlyoffice_configuration
|
||||||
from app.core.exceptions import register_exception_handlers
|
from app.core.exceptions import register_exception_handlers
|
||||||
from app.core.login_crypto import validate_login_crypto_configuration
|
from app.core.login_crypto import validate_login_crypto_configuration
|
||||||
from app.crud.user import ensure_admin_exists
|
from app.crud.user import ensure_admin_exists
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.session import SessionLocal, engine
|
from app.db.session import SessionLocal, engine
|
||||||
from app.services.visit_scheduler import run_daily_lost_visit_job
|
from app.services.visit_scheduler import run_daily_lost_visit_job
|
||||||
|
from app.services.notification_scheduler import run_notification_sync_job
|
||||||
from app.services.permission_log_writer import start_log_writer, stop_log_writer
|
from app.services.permission_log_writer import start_log_writer, stop_log_writer
|
||||||
from app.services.permission_metric_aggregator import run_hourly_metric_aggregation
|
from app.services.permission_metric_aggregator import run_hourly_metric_aggregation
|
||||||
|
from app.services.source_location_aggregator import run_hourly_source_location_aggregation
|
||||||
|
from app.services.monitoring_server_location import resolve_monitoring_server_location
|
||||||
|
from app.services.monitoring_retention import run_monitoring_retention
|
||||||
from app.services.security_access_log_writer import (
|
from app.services.security_access_log_writer import (
|
||||||
get_security_log_writer,
|
get_security_log_writer,
|
||||||
start_security_log_writer,
|
start_security_log_writer,
|
||||||
stop_security_log_writer,
|
stop_security_log_writer,
|
||||||
)
|
)
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
|
from app.core.deps import get_db_session
|
||||||
|
from app.core.request_context import (
|
||||||
|
build_request_audit_context,
|
||||||
|
build_request_snapshot,
|
||||||
|
build_sanitized_request_headers,
|
||||||
|
get_request_audit_context,
|
||||||
|
reset_request_audit_context,
|
||||||
|
resolve_ctms_client_type,
|
||||||
|
resolve_client_ip,
|
||||||
|
set_request_audit_context,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("ctms.setup_config")
|
logger = logging.getLogger("ctms.setup_config")
|
||||||
UUID_RE = re.compile(
|
UUID_RE = re.compile(
|
||||||
@@ -36,8 +54,12 @@ setup_config_stats: dict[str, int] = defaultdict(int)
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
|
await resolve_monitoring_server_location()
|
||||||
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
|
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
|
||||||
aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event))
|
aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event))
|
||||||
|
source_location_aggregator_task = asyncio.create_task(
|
||||||
|
run_hourly_source_location_aggregation(stop_event)
|
||||||
|
)
|
||||||
await start_log_writer()
|
await start_log_writer()
|
||||||
await start_security_log_writer()
|
await start_security_log_writer()
|
||||||
if settings.ENV == "development":
|
if settings.ENV == "development":
|
||||||
@@ -46,12 +68,17 @@ async def lifespan(_: FastAPI):
|
|||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
await ensure_admin_exists(session)
|
await ensure_admin_exists(session)
|
||||||
|
notification_scheduler_task = asyncio.create_task(run_notification_sync_job(stop_event))
|
||||||
|
retention_task = asyncio.create_task(run_monitoring_retention(stop_event))
|
||||||
yield
|
yield
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
await stop_log_writer()
|
await stop_log_writer()
|
||||||
await stop_security_log_writer()
|
await stop_security_log_writer()
|
||||||
await scheduler_task
|
await scheduler_task
|
||||||
await aggregator_task
|
await aggregator_task
|
||||||
|
await source_location_aggregator_task
|
||||||
|
await notification_scheduler_task
|
||||||
|
await retention_task
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_legacy_primary_keys(conn) -> None:
|
async def _ensure_legacy_primary_keys(conn) -> None:
|
||||||
@@ -89,6 +116,7 @@ async def _ensure_legacy_primary_keys(conn) -> None:
|
|||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
validate_login_crypto_configuration()
|
validate_login_crypto_configuration()
|
||||||
|
validate_onlyoffice_configuration()
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="CTMS 后端 API",
|
title="CTMS 后端 API",
|
||||||
description="临床试验项目管理系统后端接口文档",
|
description="临床试验项目管理系统后端接口文档",
|
||||||
@@ -116,19 +144,35 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=get_cors_allowed_origins(),
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=[
|
||||||
|
"Accept",
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-CTMS-Client-Source",
|
||||||
|
"X-CTMS-Client-Type",
|
||||||
|
"X-CTMS-Client-Version",
|
||||||
|
"X-CTMS-Client-Platform",
|
||||||
|
"X-CTMS-Build-Channel",
|
||||||
|
"X-CTMS-Build-Commit",
|
||||||
|
"X-Request-ID",
|
||||||
|
"X-Correlation-ID",
|
||||||
|
],
|
||||||
|
expose_headers=["X-Request-ID"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def setup_config_monitoring_middleware(request, call_next):
|
async def setup_config_monitoring_middleware(request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
|
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
|
||||||
should_security_log = path.startswith("/api/")
|
should_security_log = path.startswith("/api/") and path != "/api/v1/auth/session/heartbeat"
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
|
audit_context = build_request_audit_context(request)
|
||||||
|
audit_context_token = set_request_audit_context(audit_context)
|
||||||
status_code = 500
|
status_code = 500
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -149,6 +193,8 @@ def create_app() -> FastAPI:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
status_code = int(response.status_code)
|
status_code = int(response.status_code)
|
||||||
|
if audit_context.request_id:
|
||||||
|
response.headers["X-Request-ID"] = audit_context.request_id
|
||||||
if is_setup_config_path:
|
if is_setup_config_path:
|
||||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||||
@@ -182,6 +228,8 @@ def create_app() -> FastAPI:
|
|||||||
if should_security_log:
|
if should_security_log:
|
||||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||||
return response
|
return response
|
||||||
|
finally:
|
||||||
|
reset_request_audit_context(audit_context_token)
|
||||||
|
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|
||||||
@@ -209,6 +257,36 @@ def create_app() -> FastAPI:
|
|||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/readyz",
|
||||||
|
tags=["health"],
|
||||||
|
summary="服务就绪检查",
|
||||||
|
description="验证应用可访问数据库;失败时返回 503。",
|
||||||
|
)
|
||||||
|
async def readiness(db=Depends(get_db_session)):
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
try:
|
||||||
|
await db.execute(text("SELECT 1"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Readiness database check failed")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={
|
||||||
|
"status": "not_ready",
|
||||||
|
"database": {
|
||||||
|
"status": "unhealthy",
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"database": {
|
||||||
|
"status": "healthy",
|
||||||
|
"latency_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/health/setup-config-stats",
|
"/health/setup-config-stats",
|
||||||
tags=["health"],
|
tags=["health"],
|
||||||
@@ -225,22 +303,14 @@ def create_app() -> FastAPI:
|
|||||||
return {"totals": totals, "by_endpoint": summary}
|
return {"totals": totals, "by_endpoint": summary}
|
||||||
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
app.include_router(onlyoffice_internal_router)
|
||||||
|
app.include_router(collaboration_internal_router)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_client_ip(request) -> str | None:
|
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
|
||||||
if forwarded:
|
|
||||||
return forwarded.split(",")[0].strip()
|
|
||||||
real_ip = request.headers.get("x-real-ip")
|
|
||||||
if real_ip:
|
|
||||||
return real_ip.strip()
|
|
||||||
return request.client.host if request.client else None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_auth_context(request) -> tuple[str, str | None]:
|
def _resolve_auth_context(request) -> tuple[str, str | None]:
|
||||||
authorization = request.headers.get("authorization") or ""
|
authorization = request.headers.get("authorization") or ""
|
||||||
if not authorization.lower().startswith("bearer "):
|
if not authorization.lower().startswith("bearer "):
|
||||||
@@ -261,14 +331,23 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
|
|||||||
if not writer:
|
if not writer:
|
||||||
return
|
return
|
||||||
auth_status, user_identifier = _resolve_auth_context(request)
|
auth_status, user_identifier = _resolve_auth_context(request)
|
||||||
|
context = get_request_audit_context()
|
||||||
writer.enqueue(
|
writer.enqueue(
|
||||||
{
|
{
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
"path": path,
|
"path": path,
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
||||||
"client_ip": _resolve_client_ip(request),
|
"client_ip": resolve_client_ip(request),
|
||||||
"user_agent": request.headers.get("user-agent"),
|
"user_agent": request.headers.get("user-agent"),
|
||||||
|
"client_type": resolve_ctms_client_type(request.headers),
|
||||||
|
"client_version": request.headers.get("x-ctms-client-version"),
|
||||||
|
"client_platform": request.headers.get("x-ctms-client-platform"),
|
||||||
|
"build_channel": request.headers.get("x-ctms-build-channel"),
|
||||||
|
"build_commit": request.headers.get("x-ctms-build-commit"),
|
||||||
|
"request_headers": context.request_headers if context else build_sanitized_request_headers(request),
|
||||||
|
"request_snapshot": context.request_snapshot if context else build_request_snapshot(request),
|
||||||
|
"request_id": context.request_id if context else None,
|
||||||
"auth_status": auth_status,
|
"auth_status": auth_status,
|
||||||
"user_identifier": user_identifier,
|
"user_identifier": user_identifier,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from app.db.base_class import Base
|
|||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
__table_args__ = (Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),)
|
__table_args__ = (
|
||||||
|
Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),
|
||||||
|
Index("ix_audit_logs_operator_created", "operator_id", "created_at"),
|
||||||
|
Index("ix_audit_logs_client_ip_created", "client_ip", "created_at"),
|
||||||
|
Index("ix_audit_logs_client_source_created", "client_type", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
|
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
|
||||||
@@ -23,4 +28,11 @@ class AuditLog(Base):
|
|||||||
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
operator_role: Mapped[str] = mapped_column(String(50), nullable=False)
|
operator_role: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
|
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||||
|
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
|
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
|
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationFolder(Base):
|
||||||
|
__tablename__ = "collaboration_folders"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_collaboration_folders_study_parent", "study_id", "parent_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
|
||||||
|
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationFile(Base):
|
||||||
|
__tablename__ = "collaboration_files"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_collaboration_files_study_folder", "study_id", "folder_id"),
|
||||||
|
Index("ix_collaboration_files_study_status", "study_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
|
||||||
|
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
file_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
extension: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE")
|
||||||
|
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
current_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
generation: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
|
||||||
|
allow_export: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
allow_edit_request: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
allow_sheet_structure_edit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
|
sheet_structure_protection_backup: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationMember(Base):
|
||||||
|
__tablename__ = "collaboration_members"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"),
|
||||||
|
Index("ix_collaboration_members_user", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
invited_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationEditRequest(Base):
|
||||||
|
__tablename__ = "collaboration_edit_requests"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_collaboration_edit_requests_file_status", "file_id", "status"),
|
||||||
|
Index(
|
||||||
|
"uq_collaboration_edit_requests_pending_user",
|
||||||
|
"file_id",
|
||||||
|
"requester_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("status = 'PENDING'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
requester_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="PENDING", server_default="PENDING")
|
||||||
|
resolved_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationShareLink(Base):
|
||||||
|
__tablename__ = "collaboration_share_links"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("file_id", name="uq_collaboration_share_link_file"),
|
||||||
|
Index("ix_collaboration_share_links_enabled_expiry", "enabled", "expires_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
access_mode: Mapped[str] = mapped_column(String(12), nullable=False, default="VIEW", server_default="VIEW")
|
||||||
|
expiry_policy: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="SEVEN_DAYS", server_default="SEVEN_DAYS"
|
||||||
|
)
|
||||||
|
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
token_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
|
||||||
|
failed_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
last_failed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
updated_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationRevision(Base):
|
||||||
|
__tablename__ = "collaboration_revisions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"),
|
||||||
|
Index("ix_collaboration_revisions_file_created", "file_id", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
revision_no: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
parent_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
source: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||||
|
change_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationSession(Base):
|
||||||
|
__tablename__ = "collaboration_sessions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("document_key", name="uq_collaboration_session_document_key"),
|
||||||
|
Index("ix_collaboration_sessions_file_status", "file_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
base_revision_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id"), nullable=False
|
||||||
|
)
|
||||||
|
document_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
generation: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE")
|
||||||
|
started_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
active_users: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
last_callback_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationCallbackReceipt(Base):
|
||||||
|
__tablename__ = "collaboration_callback_receipts"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
fingerprint: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
callback_status: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
result: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||||
|
revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, UniqueConstraint, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopNotificationSubscription(Base):
|
||||||
|
__tablename__ = "desktop_notification_subscriptions"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopNotificationDelivery(Base):
|
||||||
|
__tablename__ = "desktop_notification_deliveries"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "distribution_id", name="uq_desktop_notification_user_distribution"),
|
||||||
|
UniqueConstraint("user_id", "notification_id", name="uq_desktop_notification_user_notification"),
|
||||||
|
Index("ix_desktop_notification_claim", "user_id", "delivered_at", "claimed_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
distribution_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
notification_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("notifications.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
claim_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
|
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -47,6 +47,7 @@ class DocumentVersion(Base):
|
|||||||
effective_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
effective_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
superseded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
superseded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
|
file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
original_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpSecurity(str, enum.Enum):
|
||||||
|
NONE = "NONE"
|
||||||
|
SSL = "SSL"
|
||||||
|
STARTTLS = "STARTTLS"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerificationPurpose(str, enum.Enum):
|
||||||
|
REGISTER = "REGISTER"
|
||||||
|
PASSWORD_RESET = "PASSWORD_RESET"
|
||||||
|
PASSWORD_RESET_LINK = "PASSWORD_RESET_LINK"
|
||||||
|
|
||||||
|
|
||||||
|
class SystemEmailSettings(Base):
|
||||||
|
__tablename__ = "system_email_settings"
|
||||||
|
__table_args__ = (UniqueConstraint("register_domain", name="uq_system_email_settings_register_domain"),)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
register_domain: Mapped[str] = mapped_column(String(255), nullable=False, default="huapont.cn")
|
||||||
|
smtp_host: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
smtp_security: Mapped[SmtpSecurity] = mapped_column(
|
||||||
|
Enum(SmtpSecurity, name="smtp_security"),
|
||||||
|
nullable=False,
|
||||||
|
default=SmtpSecurity.SSL,
|
||||||
|
server_default=SmtpSecurity.SSL.value,
|
||||||
|
)
|
||||||
|
smtp_username: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
smtp_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
sender_email: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
sender_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
allowed_register_domain: Mapped[str] = mapped_column(String(255), nullable=False, default="huapont.cn")
|
||||||
|
verification_code_ttl_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
|
||||||
|
send_cooldown_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
|
||||||
|
max_verify_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||||
|
updated_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerificationCode(Base):
|
||||||
|
__tablename__ = "email_verification_codes"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
purpose: Mapped[EmailVerificationPurpose] = mapped_column(
|
||||||
|
Enum(EmailVerificationPurpose, name="email_verification_purpose"),
|
||||||
|
nullable=False,
|
||||||
|
default=EmailVerificationPurpose.REGISTER,
|
||||||
|
server_default=EmailVerificationPurpose.REGISTER.value,
|
||||||
|
)
|
||||||
|
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"),
|
||||||
|
Index(
|
||||||
|
"ix_notifications_recipient_study_state",
|
||||||
|
"recipient_id",
|
||||||
|
"study_id",
|
||||||
|
"resolved_at",
|
||||||
|
"read_at",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
Index("ix_notifications_source", "source_type", "source_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("studies.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
recipient_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
category: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
priority: Mapped[str] = mapped_column(String(16), nullable=False, default="NORMAL", server_default="NORMAL")
|
||||||
|
title: Mapped[str] = mapped_column(String(180), nullable=False)
|
||||||
|
message: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
action_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
source_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
source_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
dedupe_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
requires_action: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default="true"
|
||||||
|
)
|
||||||
|
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -6,13 +6,15 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, String
|
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Index, String
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.db.base_class import Base
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
JSONB_TYPE = JSON().with_variant(JSONB, "postgresql")
|
||||||
|
|
||||||
|
|
||||||
class PermissionAccessLog(Base):
|
class PermissionAccessLog(Base):
|
||||||
__tablename__ = "permission_access_logs"
|
__tablename__ = "permission_access_logs"
|
||||||
@@ -22,6 +24,9 @@ class PermissionAccessLog(Base):
|
|||||||
Index("ix_perm_log_endpoint_created", "endpoint_key", "created_at"),
|
Index("ix_perm_log_endpoint_created", "endpoint_key", "created_at"),
|
||||||
Index("ix_perm_log_created_at", "created_at"),
|
Index("ix_perm_log_created_at", "created_at"),
|
||||||
Index("ix_perm_log_allowed", "allowed", "created_at"),
|
Index("ix_perm_log_allowed", "allowed", "created_at"),
|
||||||
|
Index("ix_perm_log_ip_created", "ip_address", "created_at"),
|
||||||
|
Index("ix_perm_log_client_source_created", "client_type", "created_at"),
|
||||||
|
Index("ix_perm_log_request_id", "request_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
@@ -38,6 +43,15 @@ class PermissionAccessLog(Base):
|
|||||||
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
|
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||||
|
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
|
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
|
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
request_headers: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
|
||||||
|
request_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
|
||||||
|
request_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Float, Index, Integer, String
|
from sqlalchemy import JSON, DateTime, Float, Index, Integer, String
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.db.base_class import Base
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
JSONB_TYPE = JSON().with_variant(JSONB, "postgresql")
|
||||||
|
|
||||||
|
|
||||||
class SecurityAccessLog(Base):
|
class SecurityAccessLog(Base):
|
||||||
__tablename__ = "security_access_logs"
|
__tablename__ = "security_access_logs"
|
||||||
@@ -21,6 +23,9 @@ class SecurityAccessLog(Base):
|
|||||||
Index("ix_security_log_ip_created", "client_ip", "created_at"),
|
Index("ix_security_log_ip_created", "client_ip", "created_at"),
|
||||||
Index("ix_security_log_status_created", "status_code", "created_at"),
|
Index("ix_security_log_status_created", "status_code", "created_at"),
|
||||||
Index("ix_security_log_auth_created", "auth_status", "created_at"),
|
Index("ix_security_log_auth_created", "auth_status", "created_at"),
|
||||||
|
Index("ix_security_log_request_id", "request_id"),
|
||||||
|
Index("ix_security_log_category_created", "category", "created_at"),
|
||||||
|
Index("ix_security_log_severity_created", "severity", "created_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
@@ -30,8 +35,18 @@ class SecurityAccessLog(Base):
|
|||||||
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
|
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||||
|
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
|
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
|
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
request_headers: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
|
||||||
|
request_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
|
||||||
auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
|
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
|
||||||
|
request_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||||
|
category: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
|
||||||
|
severity: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Hourly source-location rollup for monitoring analytics."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SourceLocationSnapshot(Base):
|
||||||
|
__tablename__ = "source_location_snapshots"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("bucket_time", "ip_hash", "user_hash", name="uq_source_location_bucket_identity"),
|
||||||
|
Index("ix_source_location_bucket", "bucket_time"),
|
||||||
|
Index("ix_source_location_country_bucket", "country_code", "bucket_time"),
|
||||||
|
Index("ix_source_location_risk_bucket", "high_risk_count", "bucket_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
bucket_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
ip_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
user_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
country: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
country_code: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
||||||
|
province: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
region_code: Mapped[str] = mapped_column(String(24), nullable=False, default="")
|
||||||
|
city: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
isp: Mapped[str] = mapped_column(String(160), nullable=False, default="")
|
||||||
|
location: Mapped[str] = mapped_column(String(320), nullable=False, default="")
|
||||||
|
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
accuracy_level: Mapped[str] = mapped_column(String(16), nullable=False, default="unknown")
|
||||||
|
allowed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
denied_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
security_event_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
high_risk_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
auth_failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserLoginSession(Base):
|
||||||
|
"""Server-side login activity record without storing credentials."""
|
||||||
|
|
||||||
|
__tablename__ = "user_login_sessions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
client_type: Mapped[str] = mapped_column(String(16), nullable=False, default="web")
|
||||||
|
client_platform: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
client_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
client_source: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
login_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
end_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
@@ -7,10 +7,27 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
|
|
||||||
class AuditLogRead(BaseModel):
|
class AuditLogRead(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
|
study_id: Optional[uuid.UUID] = None
|
||||||
|
entity_type: str
|
||||||
|
entity_id: Optional[uuid.UUID] = None
|
||||||
action: str
|
action: str
|
||||||
detail: Optional[str]
|
detail: Optional[str]
|
||||||
operator_id: uuid.UUID
|
operator_id: uuid.UUID
|
||||||
|
operator_name: Optional[str] = None
|
||||||
|
operator_email: Optional[str] = None
|
||||||
operator_role: str
|
operator_role: str
|
||||||
|
client_ip: Optional[str] = None
|
||||||
|
ip_location: str = ""
|
||||||
|
ip_country: str = ""
|
||||||
|
ip_province: str = ""
|
||||||
|
ip_city: str = ""
|
||||||
|
ip_isp: str = ""
|
||||||
|
user_agent: Optional[str] = None
|
||||||
|
client_type: Optional[str] = None
|
||||||
|
client_version: Optional[str] = None
|
||||||
|
client_platform: Optional[str] = None
|
||||||
|
build_channel: Optional[str] = None
|
||||||
|
build_commit: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
CollaborationFileType = Literal["word", "cell", "slide"]
|
||||||
|
CollaborationMemberRole = Literal["EDITOR", "MANAGER"]
|
||||||
|
CollaborationFileStatus = Literal["ACTIVE", "ARCHIVED", "DELETED"]
|
||||||
|
CollaborationShareAccessMode = Literal["VIEW", "EDIT"]
|
||||||
|
CollaborationShareExpiryPolicy = Literal["ONE_DAY", "SEVEN_DAYS", "THIRTY_DAYS", "PERMANENT"]
|
||||||
|
CollaborationEditRequestStatus = Literal["PENDING", "APPROVED", "REJECTED"]
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationFolderCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
parent_id: Optional[uuid.UUID] = None
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def normalize_name(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("文件夹名称不能为空")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationFolderUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(default=None, min_length=1, max_length=120)
|
||||||
|
parent_id: Optional[uuid.UUID] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationFolderRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
parent_id: Optional[uuid.UUID]
|
||||||
|
name: str
|
||||||
|
sort_order: int
|
||||||
|
created_by: uuid.UUID
|
||||||
|
deleted_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationFileCreate(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=240)
|
||||||
|
file_type: CollaborationFileType
|
||||||
|
folder_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationFileUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(default=None, min_length=1, max_length=240)
|
||||||
|
folder_id: Optional[uuid.UUID] = None
|
||||||
|
status: Optional[Literal["ACTIVE", "ARCHIVED"]] = None
|
||||||
|
allow_export: Optional[bool] = None
|
||||||
|
allow_edit_request: Optional[bool] = None
|
||||||
|
allow_sheet_structure_edit: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationRevisionRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
file_id: uuid.UUID
|
||||||
|
revision_no: int
|
||||||
|
parent_revision_id: Optional[uuid.UUID]
|
||||||
|
original_filename: str
|
||||||
|
file_hash: str
|
||||||
|
file_size: int
|
||||||
|
mime_type: str
|
||||||
|
source: str
|
||||||
|
change_summary: Optional[str]
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_by_name: Optional[str] = None
|
||||||
|
created_by_avatar_url: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationRevisionUpdate(BaseModel):
|
||||||
|
change_summary: str = Field(min_length=1, max_length=240)
|
||||||
|
|
||||||
|
@field_validator("change_summary")
|
||||||
|
@classmethod
|
||||||
|
def normalize_change_summary(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("版本名称不能为空")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationRevisionCopyRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=240)
|
||||||
|
folder_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
@field_validator("title")
|
||||||
|
@classmethod
|
||||||
|
def normalize_title(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("文件名不能为空")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationFileCollaboratorRead(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
full_name: str
|
||||||
|
role: CollaborationMemberRole
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationFileRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
folder_id: Optional[uuid.UUID]
|
||||||
|
title: str
|
||||||
|
file_type: CollaborationFileType
|
||||||
|
extension: str
|
||||||
|
status: CollaborationFileStatus
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
current_revision_id: Optional[uuid.UUID]
|
||||||
|
generation: int
|
||||||
|
allow_export: bool = False
|
||||||
|
allow_edit_request: bool = False
|
||||||
|
allow_sheet_structure_edit: bool = True
|
||||||
|
deleted_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
owner_name: Optional[str] = None
|
||||||
|
folder_name: Optional[str] = None
|
||||||
|
current_revision_no: Optional[int] = None
|
||||||
|
current_revision_file_size: Optional[int] = None
|
||||||
|
current_revision_mime_type: Optional[str] = None
|
||||||
|
current_revision_created_at: Optional[datetime] = None
|
||||||
|
collaboration_role: Optional[CollaborationMemberRole] = None
|
||||||
|
collaborators: list[CollaborationFileCollaboratorRead] = Field(default_factory=list)
|
||||||
|
can_edit: bool = False
|
||||||
|
can_manage: bool = False
|
||||||
|
can_export: bool = False
|
||||||
|
can_request_edit: bool = False
|
||||||
|
edit_request_status: Optional[CollaborationEditRequestStatus] = None
|
||||||
|
can_transfer_ownership: bool = False
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationMemberUpsert(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
role: CollaborationMemberRole
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationMemberRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
file_id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
role: CollaborationMemberRole
|
||||||
|
invited_by: uuid.UUID
|
||||||
|
full_name: str
|
||||||
|
email: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationEditRequestRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
file_id: uuid.UUID
|
||||||
|
requester_id: uuid.UUID
|
||||||
|
requester_name: str
|
||||||
|
requester_email: str
|
||||||
|
status: CollaborationEditRequestStatus
|
||||||
|
resolved_by: Optional[uuid.UUID]
|
||||||
|
resolved_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationEditRequestResolve(BaseModel):
|
||||||
|
status: Literal["APPROVED", "REJECTED"]
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationOwnershipTransferRequest(BaseModel):
|
||||||
|
new_owner_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationShareLinkUpdate(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
access_mode: CollaborationShareAccessMode = "VIEW"
|
||||||
|
expiry_policy: CollaborationShareExpiryPolicy = "SEVEN_DAYS"
|
||||||
|
password_mode: Literal["KEEP", "SET", "CLEAR"] = "KEEP"
|
||||||
|
password: Optional[str] = Field(default=None, min_length=4, max_length=64)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_password_change(self):
|
||||||
|
if self.password_mode == "SET" and not self.password:
|
||||||
|
raise ValueError("设置链接密码时必须提供密码")
|
||||||
|
if self.password_mode != "SET" and self.password is not None:
|
||||||
|
raise ValueError("仅在设置链接密码时允许提交密码")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationShareLinkRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
file_id: uuid.UUID
|
||||||
|
enabled: bool
|
||||||
|
access_mode: CollaborationShareAccessMode
|
||||||
|
expiry_policy: CollaborationShareExpiryPolicy
|
||||||
|
expires_at: Optional[datetime]
|
||||||
|
has_password: bool
|
||||||
|
share_path: str = "/collaboration/share"
|
||||||
|
share_token: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationPublicShareMetadata(BaseModel):
|
||||||
|
file_name: str
|
||||||
|
file_type: CollaborationFileType
|
||||||
|
access_mode: Literal["view", "edit"]
|
||||||
|
allow_export: bool
|
||||||
|
requires_password: bool
|
||||||
|
expires_at: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationSharePasswordRequest(BaseModel):
|
||||||
|
password: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationShareAccessGrant(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationPublicEditorConfigRequest(BaseModel):
|
||||||
|
access_token: Optional[str] = Field(default=None, max_length=2048)
|
||||||
|
client_id: str = Field(min_length=8, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
|
||||||
|
display_name: str = Field(default="链接访客", min_length=1, max_length=40)
|
||||||
|
|
||||||
|
@field_validator("display_name")
|
||||||
|
@classmethod
|
||||||
|
def normalize_display_name(cls, value: str) -> str:
|
||||||
|
return value.strip() or "链接访客"
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationCandidateRead(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
full_name: str
|
||||||
|
email: str
|
||||||
|
role_in_study: str
|
||||||
|
can_be_editor: bool
|
||||||
|
can_be_manager: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationEditorConfigRead(BaseModel):
|
||||||
|
file_id: uuid.UUID
|
||||||
|
file_name: str
|
||||||
|
access_mode: Literal["view", "edit"]
|
||||||
|
can_save_as: bool = False
|
||||||
|
can_download: bool = False
|
||||||
|
can_request_edit: bool = False
|
||||||
|
host_path: str = "/onlyoffice-host.html"
|
||||||
|
expires_at: datetime
|
||||||
|
config: dict
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationExportRecord(BaseModel):
|
||||||
|
file_type: str = Field(min_length=1, max_length=16, pattern=r"^[a-z0-9]+$")
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationCallbackPayload(BaseModel):
|
||||||
|
key: str
|
||||||
|
status: int
|
||||||
|
url: Optional[str] = None
|
||||||
|
changesurl: Optional[str] = None
|
||||||
|
filetype: Optional[str] = None
|
||||||
|
forcesavetype: Optional[int] = None
|
||||||
|
userdata: Optional[str] = None
|
||||||
|
users: list[str] = Field(default_factory=list)
|
||||||
|
actions: list[dict] = Field(default_factory=list)
|
||||||
|
history: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationRestoreRequest(BaseModel):
|
||||||
|
change_summary: Optional[str] = Field(default=None, max_length=500)
|
||||||
@@ -36,6 +36,7 @@ class DocumentVersionRead(BaseModel):
|
|||||||
effective_at: Optional[datetime] = None
|
effective_at: Optional[datetime] = None
|
||||||
superseded_at: Optional[datetime] = None
|
superseded_at: Optional[datetime] = None
|
||||||
file_uri: str
|
file_uri: str
|
||||||
|
original_filename: Optional[str] = None
|
||||||
file_hash: str
|
file_hash: str
|
||||||
file_size: int
|
file_size: int
|
||||||
mime_type: Optional[str] = None
|
mime_type: Optional[str] = None
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
|
SmtpSecurityValue = Literal["NONE", "SSL", "STARTTLS"]
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsRead(BaseModel):
|
||||||
|
register_domain: str = "huapont.cn"
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 465
|
||||||
|
smtp_security: SmtpSecurityValue = "SSL"
|
||||||
|
smtp_username: str = ""
|
||||||
|
smtp_password_configured: bool = False
|
||||||
|
sender_email: str = ""
|
||||||
|
sender_name: Optional[str] = "CTMS 系统"
|
||||||
|
allowed_register_domain: str = "huapont.cn,qq.com"
|
||||||
|
verification_code_ttl_minutes: int = 10
|
||||||
|
send_cooldown_seconds: int = 60
|
||||||
|
max_verify_attempts: int = 5
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsUpdate(BaseModel):
|
||||||
|
smtp_host: str = Field(default="", max_length=255)
|
||||||
|
smtp_port: int = Field(ge=1, le=65535)
|
||||||
|
smtp_security: SmtpSecurityValue = "SSL"
|
||||||
|
smtp_username: str = Field(default="", max_length=255)
|
||||||
|
smtp_password: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
sender_email: str = Field(default="", max_length=255)
|
||||||
|
sender_name: Optional[str] = Field(default="CTMS 系统", max_length=255)
|
||||||
|
allowed_register_domain: str = Field(default="", max_length=255)
|
||||||
|
verification_code_ttl_minutes: int = Field(default=10, ge=1, le=60)
|
||||||
|
send_cooldown_seconds: int = Field(default=60, ge=10, le=3600)
|
||||||
|
max_verify_attempts: int = Field(default=5, ge=1, le=20)
|
||||||
|
|
||||||
|
@field_validator("allowed_register_domain")
|
||||||
|
@classmethod
|
||||||
|
def normalize_domain(cls, value: str) -> str:
|
||||||
|
domains = []
|
||||||
|
for item in value.replace(",", ",").split(","):
|
||||||
|
domain = item.strip().lower().lstrip("@")
|
||||||
|
if domain and domain not in domains:
|
||||||
|
domains.append(domain)
|
||||||
|
if not domains:
|
||||||
|
raise ValueError("至少配置一个允许注册邮箱域名")
|
||||||
|
return ",".join(domains)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTestRequest(BaseModel):
|
||||||
|
recipient_email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class EmailDomainCreateRequest(BaseModel):
|
||||||
|
register_domain: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("register_domain")
|
||||||
|
@classmethod
|
||||||
|
def normalize_domain(cls, value: str) -> str:
|
||||||
|
return value.strip().lower().lstrip("@")
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsListResponse(BaseModel):
|
||||||
|
items: list[EmailSettingsRead]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterEmailCodeSendRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterEmailCodeVerifyRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
code: str = Field(min_length=4, max_length=12)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetLinkSendRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
code: str = Field(min_length=4, max_length=12)
|
||||||
|
password: str = Field(min_length=8, max_length=72)
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def validate_password_strength(cls, value: str) -> str:
|
||||||
|
has_letter = any(ch.isalpha() for ch in value)
|
||||||
|
has_number = any(ch.isdigit() for ch in value)
|
||||||
|
if not has_letter or not has_number:
|
||||||
|
raise ValueError("密码需至少 8 位且包含字母和数字")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetCodeVerifyRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
code: str = Field(min_length=4, max_length=12)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetTokenRequest(BaseModel):
|
||||||
|
token: str = Field(min_length=20, max_length=200)
|
||||||
|
password: str = Field(min_length=8, max_length=72)
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def validate_password_strength(cls, value: str) -> str:
|
||||||
|
has_letter = any(ch.isalpha() for ch in value)
|
||||||
|
has_number = any(ch.isdigit() for ch in value)
|
||||||
|
if not has_letter or not has_number:
|
||||||
|
raise ValueError("密码需至少 8 位且包含字母和数字")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCodeResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCodeVerifyResponse(BaseModel):
|
||||||
|
verified: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetCodeVerifyResponse(BaseModel):
|
||||||
|
verified: bool
|
||||||
|
reset_token: str
|
||||||
@@ -16,5 +16,59 @@ class NotificationItem(BaseModel):
|
|||||||
change_summary: str | None = None
|
change_summary: str | None = None
|
||||||
effective_at: datetime | None = None
|
effective_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
study_id: uuid.UUID | None = None
|
||||||
|
study_name: str | None = None
|
||||||
|
delivered_at: datetime | None = None
|
||||||
|
read_at: datetime | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopNotificationSubscriptionRead(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
enabled_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopNotificationSubscriptionUpdate(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopNotificationClaimRequest(BaseModel):
|
||||||
|
limit: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class GeneralNotificationRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
recipient_id: uuid.UUID
|
||||||
|
category: str
|
||||||
|
priority: str
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
action_path: str | None = None
|
||||||
|
source_type: str
|
||||||
|
source_id: str
|
||||||
|
requires_action: bool = True
|
||||||
|
due_at: datetime | None = None
|
||||||
|
read_at: datetime | None = None
|
||||||
|
resolved_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopNotificationClaimResponse(BaseModel):
|
||||||
|
claim_token: uuid.UUID | None = None
|
||||||
|
lease_expires_at: datetime | None = None
|
||||||
|
items: list[GeneralNotificationRead]
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopNotificationAckRequest(BaseModel):
|
||||||
|
claim_token: uuid.UUID
|
||||||
|
delivered_ids: list[uuid.UUID]
|
||||||
|
|
||||||
|
|
||||||
|
class GeneralNotificationFeed(BaseModel):
|
||||||
|
unread_count: int
|
||||||
|
total_count: int
|
||||||
|
items: list[GeneralNotificationRead]
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class OnlyOfficePreviewConfigRead(BaseModel):
|
||||||
|
resource_type: Literal["attachment", "version", "collaboration_revision"]
|
||||||
|
resource_id: uuid.UUID
|
||||||
|
file_name: str
|
||||||
|
host_path: str = "/onlyoffice-host.html"
|
||||||
|
expires_at: datetime
|
||||||
|
config: dict[str, Any]
|
||||||
@@ -6,6 +6,7 @@ from typing import Literal, Optional
|
|||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||||
|
|
||||||
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
|
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
|
||||||
|
LoginStatus = Literal["ONLINE", "OFFLINE"]
|
||||||
|
|
||||||
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
|
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
|
||||||
|
|
||||||
@@ -60,6 +61,11 @@ class UserRead(BaseModel):
|
|||||||
approved_at: Optional[datetime] = None
|
approved_at: Optional[datetime] = None
|
||||||
approved_by: Optional[uuid.UUID] = None
|
approved_by: Optional[uuid.UUID] = None
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
|
login_status: LoginStatus = "OFFLINE"
|
||||||
|
last_login_at: Optional[datetime] = None
|
||||||
|
last_seen_at: Optional[datetime] = None
|
||||||
|
last_client_type: Optional[Literal["web", "desktop"]] = None
|
||||||
|
active_session_count: int = 0
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -78,13 +84,21 @@ class UserResponse(UserRead):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UserAdminReviewRequest(BaseModel):
|
class UserLoginActivityRead(BaseModel):
|
||||||
action: Literal["approve", "reject"]
|
id: uuid.UUID
|
||||||
|
client_type: Literal["web", "desktop"]
|
||||||
|
client_platform: Optional[str] = None
|
||||||
|
client_version: Optional[str] = None
|
||||||
|
client_source: Optional[str] = None
|
||||||
|
login_ip: Optional[str] = None
|
||||||
|
ip_location: Optional[str] = None
|
||||||
|
login_at: datetime
|
||||||
|
last_seen_at: datetime
|
||||||
|
ended_at: Optional[datetime] = None
|
||||||
|
end_reason: Optional[str] = None
|
||||||
|
activity_status: Literal["ONLINE", "OFFLINE", "ENDED"]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
class AdminUserListResponse(BaseModel):
|
|
||||||
items: list[UserResponse]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
class UserSelfUpdate(_PasswordValidator):
|
class UserSelfUpdate(_PasswordValidator):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from anyio import to_thread
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
from app.models.collaboration import CollaborationFile, CollaborationShareLink
|
||||||
|
from app.schemas.collaboration import (
|
||||||
|
CollaborationPublicShareMetadata,
|
||||||
|
CollaborationShareAccessGrant,
|
||||||
|
CollaborationShareLinkRead,
|
||||||
|
CollaborationShareLinkUpdate,
|
||||||
|
)
|
||||||
|
from app.services import collaboration_service
|
||||||
|
|
||||||
|
|
||||||
|
SHARE_PATH = "/collaboration/share"
|
||||||
|
SHARE_ACCESS_TTL_SECONDS = 30 * 60
|
||||||
|
PASSWORD_FAILURE_WINDOW = timedelta(minutes=15)
|
||||||
|
PASSWORD_LOCK_DURATION = timedelta(minutes=15)
|
||||||
|
PASSWORD_FAILURE_LIMIT = 5
|
||||||
|
_ACCESS_PURPOSE = "ctms-collaboration-share-access"
|
||||||
|
_EXPIRY_DURATIONS = {
|
||||||
|
"ONE_DAY": timedelta(days=1),
|
||||||
|
"SEVEN_DAYS": timedelta(days=7),
|
||||||
|
"THIRTY_DAYS": timedelta(days=30),
|
||||||
|
"PERMANENT": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _signing_key() -> bytes:
|
||||||
|
return hmac.new(
|
||||||
|
settings.JWT_SECRET_KEY.encode("utf-8"),
|
||||||
|
b"ctms-collaboration-share-v1",
|
||||||
|
hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _b64encode(value: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64decode(value: str) -> bytes:
|
||||||
|
padding = "=" * (-len(value) % 4)
|
||||||
|
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def share_token(link: CollaborationShareLink) -> str:
|
||||||
|
payload = f"{link.id}.{link.token_version}".encode("ascii")
|
||||||
|
signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
||||||
|
return f"{_b64encode(payload)}.{_b64encode(signature)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_share_token(value: str | None) -> tuple[uuid.UUID, int]:
|
||||||
|
token = (value or "").strip()
|
||||||
|
if not token or len(token) > 256 or token.count(".") != 1:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
||||||
|
encoded_payload, encoded_signature = token.split(".", 1)
|
||||||
|
try:
|
||||||
|
payload = _b64decode(encoded_payload)
|
||||||
|
actual_signature = _b64decode(encoded_signature)
|
||||||
|
if (
|
||||||
|
not hmac.compare_digest(_b64encode(payload), encoded_payload)
|
||||||
|
or not hmac.compare_digest(_b64encode(actual_signature), encoded_signature)
|
||||||
|
):
|
||||||
|
raise ValueError("non-canonical token encoding")
|
||||||
|
expected_signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(actual_signature, expected_signature):
|
||||||
|
raise ValueError("signature mismatch")
|
||||||
|
raw_id, raw_version = payload.decode("ascii").split(".", 1)
|
||||||
|
return uuid.UUID(raw_id), int(raw_version)
|
||||||
|
except (ValueError, UnicodeError, TypeError, binascii.Error) as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _expiry_for_policy(policy: str, now: datetime) -> datetime | None:
|
||||||
|
duration = _EXPIRY_DURATIONS[policy]
|
||||||
|
return now + duration if duration else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_expired(link: CollaborationShareLink, now: datetime | None = None) -> bool:
|
||||||
|
return bool(link.expires_at and link.expires_at <= (now or _now()))
|
||||||
|
|
||||||
|
|
||||||
|
async def _link_for_file(
|
||||||
|
db: AsyncSession,
|
||||||
|
item: CollaborationFile,
|
||||||
|
user,
|
||||||
|
*,
|
||||||
|
create: bool,
|
||||||
|
lock: bool = False,
|
||||||
|
) -> CollaborationShareLink | None:
|
||||||
|
await collaboration_service.require_file_manager(db, item, user)
|
||||||
|
statement = select(CollaborationShareLink).where(CollaborationShareLink.file_id == item.id)
|
||||||
|
if lock:
|
||||||
|
statement = statement.with_for_update()
|
||||||
|
link = await db.scalar(statement)
|
||||||
|
if link or not create:
|
||||||
|
return link
|
||||||
|
now = _now()
|
||||||
|
link = CollaborationShareLink(
|
||||||
|
file_id=item.id,
|
||||||
|
enabled=False,
|
||||||
|
access_mode="VIEW",
|
||||||
|
expiry_policy="SEVEN_DAYS",
|
||||||
|
expires_at=now + timedelta(days=7),
|
||||||
|
created_by=user.id,
|
||||||
|
updated_by=user.id,
|
||||||
|
)
|
||||||
|
db.add(link)
|
||||||
|
await db.flush()
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
def share_link_read(link: CollaborationShareLink) -> CollaborationShareLinkRead:
|
||||||
|
return CollaborationShareLinkRead(
|
||||||
|
id=link.id,
|
||||||
|
file_id=link.file_id,
|
||||||
|
enabled=link.enabled,
|
||||||
|
access_mode=link.access_mode,
|
||||||
|
expiry_policy=link.expiry_policy,
|
||||||
|
expires_at=link.expires_at,
|
||||||
|
has_password=bool(link.password_hash),
|
||||||
|
share_path=SHARE_PATH,
|
||||||
|
share_token=share_token(link) if link.enabled else None,
|
||||||
|
created_at=link.created_at,
|
||||||
|
updated_at=link.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_share_link(
|
||||||
|
db: AsyncSession, item: CollaborationFile, user
|
||||||
|
) -> CollaborationShareLinkRead:
|
||||||
|
link = await _link_for_file(db, item, user, create=True)
|
||||||
|
assert link is not None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(link)
|
||||||
|
return share_link_read(link)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_share_link(
|
||||||
|
db: AsyncSession,
|
||||||
|
item: CollaborationFile,
|
||||||
|
payload: CollaborationShareLinkUpdate,
|
||||||
|
user,
|
||||||
|
) -> CollaborationShareLinkRead:
|
||||||
|
link = await _link_for_file(db, item, user, create=True, lock=True)
|
||||||
|
assert link is not None
|
||||||
|
now = _now()
|
||||||
|
link.enabled = payload.enabled
|
||||||
|
link.access_mode = payload.access_mode
|
||||||
|
link.expiry_policy = payload.expiry_policy
|
||||||
|
link.expires_at = _expiry_for_policy(payload.expiry_policy, now)
|
||||||
|
link.updated_by = user.id
|
||||||
|
if payload.password_mode == "SET":
|
||||||
|
link.password_hash = await to_thread.run_sync(hash_password, payload.password or "")
|
||||||
|
link.failed_attempts = 0
|
||||||
|
link.last_failed_at = None
|
||||||
|
link.locked_until = None
|
||||||
|
elif payload.password_mode == "CLEAR":
|
||||||
|
link.password_hash = None
|
||||||
|
link.failed_attempts = 0
|
||||||
|
link.last_failed_at = None
|
||||||
|
link.locked_until = None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(link)
|
||||||
|
return share_link_read(link)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_active_share(
|
||||||
|
db: AsyncSession,
|
||||||
|
token: str | None,
|
||||||
|
*,
|
||||||
|
lock: bool = False,
|
||||||
|
) -> tuple[CollaborationShareLink, CollaborationFile]:
|
||||||
|
link_id, version = _decode_share_token(token)
|
||||||
|
statement = select(CollaborationShareLink).where(CollaborationShareLink.id == link_id)
|
||||||
|
if lock:
|
||||||
|
statement = statement.with_for_update()
|
||||||
|
link = await db.scalar(statement)
|
||||||
|
if not link or link.token_version != version or not link.enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
||||||
|
if _is_expired(link):
|
||||||
|
raise HTTPException(status_code=status.HTTP_410_GONE, detail="共享链接已过期")
|
||||||
|
item = await db.get(CollaborationFile, link.file_id)
|
||||||
|
if not item or item.deleted_at or item.status != "ACTIVE":
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件不存在或已停止共享")
|
||||||
|
return link, item
|
||||||
|
|
||||||
|
|
||||||
|
async def public_metadata(
|
||||||
|
db: AsyncSession, token: str | None
|
||||||
|
) -> CollaborationPublicShareMetadata:
|
||||||
|
link, item = await resolve_active_share(db, token)
|
||||||
|
return CollaborationPublicShareMetadata(
|
||||||
|
file_name=item.title,
|
||||||
|
file_type=item.file_type,
|
||||||
|
access_mode="edit" if link.access_mode == "EDIT" else "view",
|
||||||
|
allow_export=item.allow_export,
|
||||||
|
requires_password=bool(link.password_hash),
|
||||||
|
expires_at=link.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_token(link: CollaborationShareLink) -> CollaborationShareAccessGrant:
|
||||||
|
now = _now()
|
||||||
|
expires_at = now + timedelta(seconds=SHARE_ACCESS_TTL_SECONDS)
|
||||||
|
if link.expires_at and link.expires_at < expires_at:
|
||||||
|
expires_at = link.expires_at
|
||||||
|
value = jwt.encode(
|
||||||
|
{
|
||||||
|
"purpose": _ACCESS_PURPOSE,
|
||||||
|
"sub": str(link.id),
|
||||||
|
"ver": link.token_version,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int(expires_at.timestamp()),
|
||||||
|
},
|
||||||
|
_signing_key().hex(),
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
return CollaborationShareAccessGrant(access_token=value, expires_at=expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_share_password(
|
||||||
|
db: AsyncSession,
|
||||||
|
token: str | None,
|
||||||
|
password: str,
|
||||||
|
) -> CollaborationShareAccessGrant:
|
||||||
|
link, _ = await resolve_active_share(db, token, lock=True)
|
||||||
|
if not link.password_hash:
|
||||||
|
return _grant_token(link)
|
||||||
|
now = _now()
|
||||||
|
if link.locked_until and link.locked_until > now:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="密码尝试次数过多,请稍后再试",
|
||||||
|
)
|
||||||
|
if link.last_failed_at and now - link.last_failed_at > PASSWORD_FAILURE_WINDOW:
|
||||||
|
link.failed_attempts = 0
|
||||||
|
valid = await to_thread.run_sync(verify_password, password, link.password_hash)
|
||||||
|
if not valid:
|
||||||
|
link.failed_attempts += 1
|
||||||
|
link.last_failed_at = now
|
||||||
|
if link.failed_attempts >= PASSWORD_FAILURE_LIMIT:
|
||||||
|
link.locked_until = now + PASSWORD_LOCK_DURATION
|
||||||
|
await db.commit()
|
||||||
|
if link.locked_until:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="密码尝试次数过多,请稍后再试",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接密码不正确")
|
||||||
|
link.failed_attempts = 0
|
||||||
|
link.last_failed_at = None
|
||||||
|
link.locked_until = None
|
||||||
|
await db.commit()
|
||||||
|
return _grant_token(link)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_access_grant(link: CollaborationShareLink, value: str | None) -> None:
|
||||||
|
if not link.password_hash:
|
||||||
|
return
|
||||||
|
if not value:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请输入链接密码")
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(value, _signing_key().hex(), algorithms=["HS256"])
|
||||||
|
if (
|
||||||
|
payload.get("purpose") != _ACCESS_PURPOSE
|
||||||
|
or not hmac.compare_digest(str(payload.get("sub") or ""), str(link.id))
|
||||||
|
or payload.get("ver") != link.token_version
|
||||||
|
):
|
||||||
|
raise JWTError("share grant mismatch")
|
||||||
|
except JWTError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接访问凭证已失效") from exc
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import and_, exists, or_, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.desktop_notification import (
|
||||||
|
DesktopNotificationDelivery,
|
||||||
|
DesktopNotificationSubscription,
|
||||||
|
)
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.study_member import StudyMember
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
CLAIM_LEASE = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_subscription(db: AsyncSession, user_id: uuid.UUID) -> DesktopNotificationSubscription | None:
|
||||||
|
return await db.get(DesktopNotificationSubscription, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_subscription(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
enabled: bool,
|
||||||
|
) -> DesktopNotificationSubscription:
|
||||||
|
subscription = await get_subscription(db, user_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if subscription is None:
|
||||||
|
subscription = DesktopNotificationSubscription(
|
||||||
|
user_id=user_id,
|
||||||
|
enabled=enabled,
|
||||||
|
enabled_at=now if enabled else None,
|
||||||
|
)
|
||||||
|
db.add(subscription)
|
||||||
|
elif subscription.enabled != enabled:
|
||||||
|
subscription.enabled = enabled
|
||||||
|
subscription.enabled_at = now if enabled else None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(subscription)
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime):
|
||||||
|
active_membership = exists(
|
||||||
|
select(StudyMember.id).where(
|
||||||
|
StudyMember.study_id == Notification.study_id,
|
||||||
|
StudyMember.user_id == user_id,
|
||||||
|
StudyMember.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
select(Notification, DesktopNotificationDelivery)
|
||||||
|
.outerjoin(
|
||||||
|
DesktopNotificationDelivery,
|
||||||
|
and_(
|
||||||
|
DesktopNotificationDelivery.notification_id == Notification.id,
|
||||||
|
DesktopNotificationDelivery.user_id == user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Notification.recipient_id == user_id,
|
||||||
|
Notification.created_at >= enabled_at,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
Notification.read_at.is_(None),
|
||||||
|
active_membership,
|
||||||
|
or_(
|
||||||
|
DesktopNotificationDelivery.id.is_(None),
|
||||||
|
and_(
|
||||||
|
DesktopNotificationDelivery.delivered_at.is_(None),
|
||||||
|
or_(
|
||||||
|
DesktopNotificationDelivery.claimed_at.is_(None),
|
||||||
|
DesktopNotificationDelivery.claimed_at < lease_cutoff,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(Notification.created_at.asc())
|
||||||
|
.with_for_update(of=Notification, skip_locked=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def claim_notifications(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
limit: int,
|
||||||
|
) -> tuple[uuid.UUID | None, datetime | None, list[Notification]]:
|
||||||
|
subscription = await get_subscription(db, user_id)
|
||||||
|
if not subscription or not subscription.enabled or not subscription.enabled_at:
|
||||||
|
return None, None, []
|
||||||
|
|
||||||
|
# Reconcile every active project before selecting desktop deliveries so permission
|
||||||
|
# changes fail closed and time-based reminders do not depend on opening the web Feed.
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
return None, None, []
|
||||||
|
study_ids = (await db.scalars(
|
||||||
|
select(StudyMember.study_id).where(
|
||||||
|
StudyMember.user_id == user_id,
|
||||||
|
StudyMember.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
from app.services.project_reminder_service import sync_project_reminders
|
||||||
|
|
||||||
|
for study_id in set(study_ids):
|
||||||
|
await sync_project_reminders(db, study_id, user)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = uuid.uuid4()
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
_eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE)
|
||||||
|
.limit(max(1, min(limit, 50)))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
items: list[Notification] = []
|
||||||
|
for notification, delivery in rows:
|
||||||
|
if delivery is None:
|
||||||
|
delivery = DesktopNotificationDelivery(
|
||||||
|
user_id=user_id,
|
||||||
|
notification_id=notification.id,
|
||||||
|
)
|
||||||
|
db.add(delivery)
|
||||||
|
delivery.claim_token = token
|
||||||
|
delivery.claimed_at = now
|
||||||
|
items.append(notification)
|
||||||
|
await db.commit()
|
||||||
|
if not items:
|
||||||
|
return None, None, []
|
||||||
|
return token, now + CLAIM_LEASE, items
|
||||||
|
|
||||||
|
|
||||||
|
async def acknowledge_notifications(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
claim_token: uuid.UUID,
|
||||||
|
delivered_ids: list[uuid.UUID],
|
||||||
|
) -> None:
|
||||||
|
if delivered_ids:
|
||||||
|
await db.execute(
|
||||||
|
update(DesktopNotificationDelivery)
|
||||||
|
.where(
|
||||||
|
DesktopNotificationDelivery.user_id == user_id,
|
||||||
|
DesktopNotificationDelivery.claim_token == claim_token,
|
||||||
|
DesktopNotificationDelivery.notification_id.in_(delivered_ids),
|
||||||
|
)
|
||||||
|
.values(delivered_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_notification_read(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
item = await db.scalar(select(Notification).where(
|
||||||
|
Notification.id == notification_id,
|
||||||
|
Notification.recipient_id == user_id,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
))
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
item.read_at = item.read_at or now
|
||||||
|
if not item.requires_action:
|
||||||
|
item.resolved_at = item.resolved_at or now
|
||||||
|
await db.commit()
|
||||||
@@ -6,11 +6,12 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from fastapi import HTTPException, UploadFile, status
|
from fastapi import HTTPException, UploadFile, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
|
from sqlalchemy import String, and_, cast, delete as sa_delete, or_, select, update as sa_update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_cra_site_scope
|
from app.core.deps import get_cra_site_scope
|
||||||
@@ -30,12 +31,16 @@ from app.models.audit_log import AuditLog
|
|||||||
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||||
from app.models.document import Document, DocumentScopeType, DocumentStatus
|
from app.models.document import Document, DocumentScopeType, DocumentStatus
|
||||||
from app.models.document_version import DocumentVersion, DocumentVersionStatus
|
from app.models.document_version import DocumentVersion, DocumentVersionStatus
|
||||||
|
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.study import Study
|
||||||
from app.schemas.acknowledgement import AcknowledgementCreate
|
from app.schemas.acknowledgement import AcknowledgementCreate
|
||||||
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
|
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
|
||||||
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
|
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
|
||||||
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
|
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
|
||||||
from app.schemas.notification import NotificationItem
|
from app.schemas.notification import NotificationItem
|
||||||
from app.schemas.user import UserDisplay
|
from app.schemas.user import UserDisplay
|
||||||
|
from app.services import notification_service
|
||||||
|
|
||||||
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents"
|
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents"
|
||||||
|
|
||||||
@@ -50,6 +55,29 @@ DOCUMENT_ACTION_PERMISSIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_original_filename(value: str | None) -> str:
|
||||||
|
filename = (value or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
|
||||||
|
filename = "".join(character for character in filename if ord(character) >= 32 and ord(character) != 127)
|
||||||
|
return filename[:255] or "document"
|
||||||
|
|
||||||
|
|
||||||
|
def _content_disposition(filename: str, disposition: str = "attachment") -> str:
|
||||||
|
fallback = "".join(character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_" for character in filename)
|
||||||
|
fallback = fallback or "download"
|
||||||
|
encoded = quote(filename, safe="")
|
||||||
|
return f'{disposition}; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_download_filename(version: DocumentVersion, document: Document) -> str:
|
||||||
|
"""Return a readable fallback for rows created before original_filename existed."""
|
||||||
|
stored_name = Path(version.file_uri).name
|
||||||
|
suffix = Path(stored_name).suffix
|
||||||
|
title = _safe_original_filename(document.title)
|
||||||
|
if suffix and title.lower().endswith(suffix.lower()):
|
||||||
|
return title
|
||||||
|
return f"{title}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
def _audit_detail(before: dict | None, after: dict | None) -> str:
|
def _audit_detail(before: dict | None, after: dict | None) -> str:
|
||||||
payload = {"before": before, "after": after}
|
payload = {"before": before, "after": after}
|
||||||
return json.dumps(payload, ensure_ascii=True)
|
return json.dumps(payload, ensure_ascii=True)
|
||||||
@@ -354,7 +382,8 @@ async def create_version(
|
|||||||
file_hash = hashlib.sha256(content).hexdigest()
|
file_hash = hashlib.sha256(content).hexdigest()
|
||||||
dest_dir = UPLOAD_ROOT / str(document_id)
|
dest_dir = UPLOAD_ROOT / str(document_id)
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
|
original_filename = _safe_original_filename(file.filename)
|
||||||
|
unique_name = f"{uuid.uuid4()}{Path(original_filename).suffix}"
|
||||||
dest_path = dest_dir / unique_name
|
dest_path = dest_dir / unique_name
|
||||||
async with aiofiles.open(dest_path, "wb") as out_file:
|
async with aiofiles.open(dest_path, "wb") as out_file:
|
||||||
await out_file.write(content)
|
await out_file.write(content)
|
||||||
@@ -375,6 +404,7 @@ async def create_version(
|
|||||||
status=DocumentVersionStatus.EFFECTIVE,
|
status=DocumentVersionStatus.EFFECTIVE,
|
||||||
effective_at=effective_at,
|
effective_at=effective_at,
|
||||||
file_uri=str(dest_path),
|
file_uri=str(dest_path),
|
||||||
|
original_filename=original_filename,
|
||||||
file_hash=file_hash,
|
file_hash=file_hash,
|
||||||
file_size=len(content),
|
file_size=len(content),
|
||||||
mime_type=file.content_type,
|
mime_type=file.content_type,
|
||||||
@@ -550,12 +580,11 @@ async def get_version_download_response(
|
|||||||
file_path = Path(version.file_uri)
|
file_path = Path(version.file_uri)
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在")
|
||||||
filename = file_path.name
|
filename = version.original_filename or _legacy_download_filename(version, doc)
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=str(file_path),
|
path=str(file_path),
|
||||||
filename=filename,
|
|
||||||
media_type=version.mime_type or "application/octet-stream",
|
media_type=version.mime_type or "application/octet-stream",
|
||||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
headers={"Content-Disposition": _content_disposition(filename)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -661,6 +690,10 @@ async def create_acknowledgement(
|
|||||||
if distribution.target_type == DistributionTargetType.ROLE:
|
if distribution.target_type == DistributionTargetType.ROLE:
|
||||||
if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")):
|
if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
|
||||||
|
if distribution.target_type == DistributionTargetType.SITE and not is_system_admin(current_user):
|
||||||
|
site_ids = await site_crud.list_ids_by_contact_user(db, doc.trial_id, current_user.id)
|
||||||
|
if distribution.target_id not in {str(site_id) for site_id in site_ids}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发中心范围内")
|
||||||
|
|
||||||
if payload.ack_type != AcknowledgementType.RECEIVED:
|
if payload.ack_type != AcknowledgementType.RECEIVED:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执")
|
||||||
@@ -695,6 +728,12 @@ async def create_acknowledgement(
|
|||||||
operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
|
operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
await notification_service.resolve_source_notifications(
|
||||||
|
db,
|
||||||
|
source_type="DOCUMENT_DISTRIBUTION",
|
||||||
|
source_id=str(distribution.id),
|
||||||
|
recipient_id=current_user.id,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(ack)
|
await db.refresh(ack)
|
||||||
return ack
|
return ack
|
||||||
@@ -778,9 +817,28 @@ async def list_distribution_notifications(
|
|||||||
DocumentVersion.version_no,
|
DocumentVersion.version_no,
|
||||||
DocumentVersion.change_summary,
|
DocumentVersion.change_summary,
|
||||||
DocumentVersion.effective_at,
|
DocumentVersion.effective_at,
|
||||||
|
Study.name.label("study_name"),
|
||||||
|
DesktopNotificationDelivery.delivered_at,
|
||||||
|
DesktopNotificationDelivery.read_at,
|
||||||
)
|
)
|
||||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||||
.join(Document, Distribution.document_id == Document.id)
|
.join(Document, Distribution.document_id == Document.id)
|
||||||
|
.join(Study, Document.trial_id == Study.id)
|
||||||
|
.outerjoin(
|
||||||
|
Notification,
|
||||||
|
and_(
|
||||||
|
Notification.source_type == "DOCUMENT_DISTRIBUTION",
|
||||||
|
Notification.source_id == cast(Distribution.id, String),
|
||||||
|
Notification.recipient_id == current_user.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
DesktopNotificationDelivery,
|
||||||
|
and_(
|
||||||
|
DesktopNotificationDelivery.notification_id == Notification.id,
|
||||||
|
DesktopNotificationDelivery.user_id == current_user.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
Document.trial_id == study_id,
|
Document.trial_id == study_id,
|
||||||
Distribution.status == DistributionStatus.ACTIVE,
|
Distribution.status == DistributionStatus.ACTIVE,
|
||||||
@@ -804,6 +862,10 @@ async def list_distribution_notifications(
|
|||||||
change_summary=row.change_summary,
|
change_summary=row.change_summary,
|
||||||
effective_at=row.effective_at,
|
effective_at=row.effective_at,
|
||||||
created_at=row.created_at,
|
created_at=row.created_at,
|
||||||
|
study_id=study_id,
|
||||||
|
study_name=row.study_name,
|
||||||
|
delivered_at=row.delivered_at,
|
||||||
|
read_at=row.read_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -0,0 +1,563 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import secrets
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import delete, desc, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
from app.crud import user as user_crud
|
||||||
|
from app.models.email_settings import EmailVerificationCode, EmailVerificationPurpose, SmtpSecurity, SystemEmailSettings
|
||||||
|
from app.schemas.email_settings import EmailSettingsRead, EmailSettingsUpdate
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email(email: str) -> str:
|
||||||
|
return email.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_allowed_domains(value: str) -> list[str]:
|
||||||
|
domains: list[str] = []
|
||||||
|
for item in (value or "").replace(",", ",").split(","):
|
||||||
|
domain = item.strip().lower().lstrip("@")
|
||||||
|
if domain and domain not in domains:
|
||||||
|
domains.append(domain)
|
||||||
|
return domains or ["huapont.cn", "qq.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_domain(value: str) -> str:
|
||||||
|
return value.strip().lower().lstrip("@")
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_from_email(email: str) -> str:
|
||||||
|
parts = _normalize_email(email).rsplit("@", 1)
|
||||||
|
return parts[1] if len(parts) == 2 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _as_aware(value: datetime) -> datetime:
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
source = settings.SETTINGS_ENCRYPTION_KEY or settings.JWT_SECRET_KEY
|
||||||
|
try:
|
||||||
|
return Fernet(source.encode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
key = base64.urlsafe_b64encode(hashlib.sha256(source.encode("utf-8")).digest())
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(value: str) -> str:
|
||||||
|
return _get_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _get_fernet().decrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="邮件授权码解密失败") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def list_email_settings(db: AsyncSession) -> list[SystemEmailSettings]:
|
||||||
|
result = await db.execute(select(SystemEmailSettings).order_by(SystemEmailSettings.register_domain.asc()))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_email_settings(db: AsyncSession, register_domain: str | None = None) -> SystemEmailSettings | None:
|
||||||
|
if register_domain:
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemEmailSettings).where(SystemEmailSettings.register_domain == _normalize_domain(register_domain))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
result = await db.execute(select(SystemEmailSettings).order_by(SystemEmailSettings.created_at.asc()))
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_email_settings_for_email(db: AsyncSession, email: str) -> SystemEmailSettings | None:
|
||||||
|
domain = _domain_from_email(email)
|
||||||
|
return await get_email_settings(db, domain) if domain else None
|
||||||
|
|
||||||
|
|
||||||
|
def to_email_settings_read(settings_row: SystemEmailSettings | None) -> EmailSettingsRead:
|
||||||
|
if not settings_row:
|
||||||
|
return EmailSettingsRead()
|
||||||
|
return EmailSettingsRead(
|
||||||
|
register_domain=settings_row.register_domain,
|
||||||
|
smtp_host=settings_row.smtp_host,
|
||||||
|
smtp_port=settings_row.smtp_port,
|
||||||
|
smtp_security=settings_row.smtp_security.value,
|
||||||
|
smtp_username=settings_row.smtp_username,
|
||||||
|
smtp_password_configured=bool(settings_row.smtp_password_encrypted),
|
||||||
|
sender_email=settings_row.sender_email,
|
||||||
|
sender_name=settings_row.sender_name,
|
||||||
|
allowed_register_domain=settings_row.allowed_register_domain,
|
||||||
|
verification_code_ttl_minutes=settings_row.verification_code_ttl_minutes,
|
||||||
|
send_cooldown_seconds=settings_row.send_cooldown_seconds,
|
||||||
|
max_verify_attempts=settings_row.max_verify_attempts,
|
||||||
|
updated_at=settings_row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_email_settings(
|
||||||
|
db: AsyncSession,
|
||||||
|
register_domain: str,
|
||||||
|
payload: EmailSettingsUpdate,
|
||||||
|
*,
|
||||||
|
updated_by: uuid.UUID,
|
||||||
|
) -> SystemEmailSettings:
|
||||||
|
register_domain = _normalize_domain(register_domain)
|
||||||
|
settings_row = await get_email_settings(db, register_domain)
|
||||||
|
if not settings_row:
|
||||||
|
settings_row = SystemEmailSettings(
|
||||||
|
register_domain=register_domain,
|
||||||
|
smtp_host=payload.smtp_host,
|
||||||
|
smtp_port=payload.smtp_port,
|
||||||
|
smtp_security=SmtpSecurity(payload.smtp_security),
|
||||||
|
smtp_username=payload.smtp_username,
|
||||||
|
smtp_password_encrypted=encrypt_secret(payload.smtp_password) if payload.smtp_password else None,
|
||||||
|
sender_email=str(payload.sender_email),
|
||||||
|
sender_name=payload.sender_name,
|
||||||
|
allowed_register_domain=register_domain,
|
||||||
|
verification_code_ttl_minutes=payload.verification_code_ttl_minutes,
|
||||||
|
send_cooldown_seconds=payload.send_cooldown_seconds,
|
||||||
|
max_verify_attempts=payload.max_verify_attempts,
|
||||||
|
updated_by=updated_by,
|
||||||
|
)
|
||||||
|
db.add(settings_row)
|
||||||
|
else:
|
||||||
|
settings_row.smtp_host = payload.smtp_host
|
||||||
|
settings_row.smtp_port = payload.smtp_port
|
||||||
|
settings_row.smtp_security = SmtpSecurity(payload.smtp_security)
|
||||||
|
settings_row.smtp_username = payload.smtp_username
|
||||||
|
if payload.smtp_password:
|
||||||
|
settings_row.smtp_password_encrypted = encrypt_secret(payload.smtp_password)
|
||||||
|
settings_row.sender_email = str(payload.sender_email)
|
||||||
|
settings_row.sender_name = payload.sender_name
|
||||||
|
settings_row.allowed_register_domain = register_domain
|
||||||
|
settings_row.verification_code_ttl_minutes = payload.verification_code_ttl_minutes
|
||||||
|
settings_row.send_cooldown_seconds = payload.send_cooldown_seconds
|
||||||
|
settings_row.max_verify_attempts = payload.max_verify_attempts
|
||||||
|
settings_row.updated_by = updated_by
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(settings_row)
|
||||||
|
return settings_row
|
||||||
|
|
||||||
|
|
||||||
|
async def create_email_domain(db: AsyncSession, register_domain: str, *, updated_by: uuid.UUID) -> SystemEmailSettings:
|
||||||
|
register_domain = _normalize_domain(register_domain)
|
||||||
|
existing = await get_email_settings(db, register_domain)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
settings_row = SystemEmailSettings(
|
||||||
|
register_domain=register_domain,
|
||||||
|
smtp_host="",
|
||||||
|
smtp_port=465,
|
||||||
|
smtp_security=SmtpSecurity.SSL,
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password_encrypted=None,
|
||||||
|
sender_email="",
|
||||||
|
sender_name="CTMS 系统",
|
||||||
|
allowed_register_domain=register_domain,
|
||||||
|
verification_code_ttl_minutes=10,
|
||||||
|
send_cooldown_seconds=60,
|
||||||
|
max_verify_attempts=5,
|
||||||
|
updated_by=updated_by,
|
||||||
|
)
|
||||||
|
db.add(settings_row)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(settings_row)
|
||||||
|
return settings_row
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_email_domain(db: AsyncSession, register_domain: str) -> None:
|
||||||
|
await db.execute(
|
||||||
|
delete(SystemEmailSettings).where(SystemEmailSettings.register_domain == _normalize_domain(register_domain))
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_email_settings_ready(settings_row: SystemEmailSettings | None) -> SystemEmailSettings:
|
||||||
|
if not settings_row:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮件服务尚未配置")
|
||||||
|
if not settings_row.smtp_host or not settings_row.smtp_username or not settings_row.sender_email:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮件服务配置不完整")
|
||||||
|
if not settings_row.smtp_password_encrypted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SMTP 授权码尚未配置")
|
||||||
|
return settings_row
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_allowed_register_email(email: str, settings_row: SystemEmailSettings) -> None:
|
||||||
|
domain = settings_row.register_domain
|
||||||
|
if not _normalize_email(email).endswith(f"@{domain}"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"仅允许 @{domain} 邮箱注册")
|
||||||
|
|
||||||
|
|
||||||
|
def _send_email_sync(
|
||||||
|
settings_row: SystemEmailSettings,
|
||||||
|
*,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
) -> None:
|
||||||
|
password = decrypt_secret(settings_row.smtp_password_encrypted)
|
||||||
|
if not password:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SMTP 授权码尚未配置")
|
||||||
|
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["From"] = formataddr((settings_row.sender_name or "", settings_row.sender_email))
|
||||||
|
message["To"] = to_email
|
||||||
|
message.set_content(body)
|
||||||
|
|
||||||
|
if settings_row.smtp_security == SmtpSecurity.SSL:
|
||||||
|
with smtplib.SMTP_SSL(settings_row.smtp_host, settings_row.smtp_port, timeout=15) as smtp:
|
||||||
|
smtp.login(settings_row.smtp_username, password)
|
||||||
|
smtp.send_message(message)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(settings_row.smtp_host, settings_row.smtp_port, timeout=15) as smtp:
|
||||||
|
if settings_row.smtp_security == SmtpSecurity.STARTTLS:
|
||||||
|
smtp.starttls()
|
||||||
|
smtp.login(settings_row.smtp_username, password)
|
||||||
|
smtp.send_message(message)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email(
|
||||||
|
settings_row: SystemEmailSettings,
|
||||||
|
*,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_send_email_sync, settings_row, to_email=to_email, subject=subject, body=body)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except ssl.SSLError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=(
|
||||||
|
"邮件发送失败:SMTP 安全协议或端口不匹配,"
|
||||||
|
"请确认当前后缀的 SMTP 地址、端口和安全协议。"
|
||||||
|
"SSL 通常使用 465,STARTTLS 通常使用 587。"
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
except smtplib.SMTPAuthenticationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="邮件发送失败:SMTP 账号或授权码不正确",
|
||||||
|
) from exc
|
||||||
|
except (smtplib.SMTPRecipientsRefused, smtplib.SMTPDataError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="邮箱不存在或无法接收邮件,请检查邮箱地址",
|
||||||
|
) from exc
|
||||||
|
except (smtplib.SMTPConnectError, TimeoutError, OSError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="邮件发送失败:无法连接 SMTP 服务器,请检查 SMTP 地址、端口和网络连通性",
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"邮件发送失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def send_test_email(db: AsyncSession, register_domain: str, recipient_email: str) -> None:
|
||||||
|
settings_row = ensure_email_settings_ready(await get_email_settings(db, register_domain))
|
||||||
|
await send_email(
|
||||||
|
settings_row,
|
||||||
|
to_email=recipient_email,
|
||||||
|
subject="CTMS 邮件服务测试",
|
||||||
|
body="这是一封来自 CTMS 系统的邮件服务测试邮件。收到此邮件说明 SMTP 配置可用。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_register_code(db: AsyncSession, email: str) -> None:
|
||||||
|
email = _normalize_email(email)
|
||||||
|
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||||
|
ensure_allowed_register_email(email, settings_row)
|
||||||
|
if await user_crud.get_by_email(db, email):
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
|
||||||
|
|
||||||
|
await _send_verification_code(
|
||||||
|
db,
|
||||||
|
settings_row=settings_row,
|
||||||
|
email=email,
|
||||||
|
purpose=EmailVerificationPurpose.REGISTER,
|
||||||
|
subject="CTMS 注册邮箱验证码",
|
||||||
|
body_factory=lambda code: (
|
||||||
|
f"您的 CTMS 注册验证码是:{code}\n\n"
|
||||||
|
f"验证码 {settings_row.verification_code_ttl_minutes} 分钟内有效。"
|
||||||
|
"如非本人操作,请忽略此邮件。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_password_reset_code(db: AsyncSession, email: str) -> None:
|
||||||
|
email = _normalize_email(email)
|
||||||
|
db_user = await user_crud.get_by_email(db, email)
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="该邮箱未注册")
|
||||||
|
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||||
|
await _send_verification_code(
|
||||||
|
db,
|
||||||
|
settings_row=settings_row,
|
||||||
|
email=email,
|
||||||
|
purpose=EmailVerificationPurpose.PASSWORD_RESET,
|
||||||
|
subject="CTMS 密码重置验证码",
|
||||||
|
body_factory=lambda code: (
|
||||||
|
f"您的 CTMS 密码重置验证码是:{code}\n\n"
|
||||||
|
f"验证码 {settings_row.verification_code_ttl_minutes} 分钟内有效。"
|
||||||
|
"如非本人操作,请尽快联系系统管理员。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_password_reset_link(db: AsyncSession, email: str, *, frontend_origin: str) -> None:
|
||||||
|
email = _normalize_email(email)
|
||||||
|
db_user = await user_crud.get_by_email(db, email)
|
||||||
|
if not db_user:
|
||||||
|
return
|
||||||
|
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
latest_result = await db.execute(
|
||||||
|
select(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.email == email,
|
||||||
|
EmailVerificationCode.purpose == EmailVerificationPurpose.PASSWORD_RESET_LINK,
|
||||||
|
)
|
||||||
|
.order_by(desc(EmailVerificationCode.created_at))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
latest = latest_result.scalar_one_or_none()
|
||||||
|
if latest and latest.created_at and _as_aware(latest.created_at) + timedelta(seconds=settings_row.send_cooldown_seconds) > now:
|
||||||
|
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="重置邮件发送过于频繁,请稍后再试")
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = _hash_reset_token(token)
|
||||||
|
record = EmailVerificationCode(
|
||||||
|
email=email,
|
||||||
|
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
|
||||||
|
code_hash=token_hash,
|
||||||
|
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
origin = frontend_origin.rstrip("/") if frontend_origin else ""
|
||||||
|
reset_link = f"{origin}/forgot-password?token={token}" if origin else f"/forgot-password?token={token}"
|
||||||
|
await send_email(
|
||||||
|
settings_row,
|
||||||
|
to_email=email,
|
||||||
|
subject="CTMS 密码重置链接",
|
||||||
|
body=(
|
||||||
|
"请点击以下链接重置您的 CTMS 登录密码:\n\n"
|
||||||
|
f"{reset_link}\n\n"
|
||||||
|
f"链接 {settings_row.verification_code_ttl_minutes} 分钟内有效,且只能使用一次。"
|
||||||
|
"如非本人操作,请忽略此邮件并尽快联系系统管理员。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_reset_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_verification_code(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
settings_row: SystemEmailSettings,
|
||||||
|
email: str,
|
||||||
|
purpose: EmailVerificationPurpose,
|
||||||
|
subject: str,
|
||||||
|
body_factory,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
latest_result = await db.execute(
|
||||||
|
select(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.email == email,
|
||||||
|
EmailVerificationCode.purpose == purpose,
|
||||||
|
)
|
||||||
|
.order_by(desc(EmailVerificationCode.created_at))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
latest = latest_result.scalar_one_or_none()
|
||||||
|
if latest and latest.created_at and _as_aware(latest.created_at) + timedelta(seconds=settings_row.send_cooldown_seconds) > now:
|
||||||
|
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="验证码发送过于频繁,请稍后再试")
|
||||||
|
|
||||||
|
code = f"{random.SystemRandom().randint(0, 999999):06d}"
|
||||||
|
record = EmailVerificationCode(
|
||||||
|
email=email,
|
||||||
|
purpose=purpose,
|
||||||
|
code_hash=hash_password(code),
|
||||||
|
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await send_email(
|
||||||
|
settings_row,
|
||||||
|
to_email=email,
|
||||||
|
subject=subject,
|
||||||
|
body=body_factory(code),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_register_code(db: AsyncSession, email: str, code: str) -> bool:
|
||||||
|
email = _normalize_email(email)
|
||||||
|
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||||
|
ensure_allowed_register_email(email, settings_row)
|
||||||
|
await _verify_email_code(
|
||||||
|
db,
|
||||||
|
settings_row=settings_row,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
purpose=EmailVerificationPurpose.REGISTER,
|
||||||
|
mark_verified=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_password_reset_code(db: AsyncSession, email: str, code: str) -> str:
|
||||||
|
email = _normalize_email(email)
|
||||||
|
if not await user_crud.get_by_email(db, email):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
|
||||||
|
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||||
|
await _verify_email_code(
|
||||||
|
db,
|
||||||
|
settings_row=settings_row,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
purpose=EmailVerificationPurpose.PASSWORD_RESET,
|
||||||
|
mark_verified=True,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
db.add(
|
||||||
|
EmailVerificationCode(
|
||||||
|
email=email,
|
||||||
|
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
|
||||||
|
code_hash=_hash_reset_token(token),
|
||||||
|
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_password_with_code(db: AsyncSession, email: str, code: str, password: str) -> None:
|
||||||
|
email = _normalize_email(email)
|
||||||
|
db_user = await user_crud.get_by_email(db, email)
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
|
||||||
|
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||||
|
await _verify_email_code(
|
||||||
|
db,
|
||||||
|
settings_row=settings_row,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
purpose=EmailVerificationPurpose.PASSWORD_RESET,
|
||||||
|
mark_verified=True,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db_user.password_hash = hash_password(password)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_password_with_token(db: AsyncSession, token: str, password: str) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token_hash = _hash_reset_token(token)
|
||||||
|
result = await db.execute(
|
||||||
|
select(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.code_hash == token_hash,
|
||||||
|
EmailVerificationCode.purpose == EmailVerificationPurpose.PASSWORD_RESET_LINK,
|
||||||
|
EmailVerificationCode.verified_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(desc(EmailVerificationCode.created_at))
|
||||||
|
.limit(1)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if not record or record.verified_at is not None or _as_aware(record.expires_at) < now:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
|
||||||
|
|
||||||
|
db_user = await user_crud.get_by_email(db, record.email)
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
|
||||||
|
|
||||||
|
record.verified_at = now
|
||||||
|
db_user.password_hash = hash_password(password)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_email_code(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
settings_row: SystemEmailSettings,
|
||||||
|
email: str,
|
||||||
|
code: str,
|
||||||
|
purpose: EmailVerificationPurpose,
|
||||||
|
mark_verified: bool,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> EmailVerificationCode:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = await db.execute(
|
||||||
|
select(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.email == email,
|
||||||
|
EmailVerificationCode.purpose == purpose,
|
||||||
|
)
|
||||||
|
.order_by(desc(EmailVerificationCode.created_at))
|
||||||
|
.limit(1)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if (
|
||||||
|
not record
|
||||||
|
or record.verified_at is not None
|
||||||
|
or _as_aware(record.expires_at) < now
|
||||||
|
or record.attempt_count >= settings_row.max_verify_attempts
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
|
||||||
|
record.attempt_count += 1
|
||||||
|
if not verify_password(code, record.code_hash):
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
|
||||||
|
if mark_verified:
|
||||||
|
record.verified_at = now
|
||||||
|
if commit:
|
||||||
|
await db.commit()
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_register_email_verified(db: AsyncSession, email: str) -> None:
|
||||||
|
email = _normalize_email(email)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = await db.execute(
|
||||||
|
select(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.email == email,
|
||||||
|
EmailVerificationCode.purpose == EmailVerificationPurpose.REGISTER,
|
||||||
|
EmailVerificationCode.verified_at.is_not(None),
|
||||||
|
EmailVerificationCode.expires_at >= now,
|
||||||
|
)
|
||||||
|
.order_by(desc(EmailVerificationCode.verified_at))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先完成邮箱验证码校验")
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Canonical map metadata for monitoring source locations.
|
||||||
|
|
||||||
|
ip2region provides administrative names but no coordinates. This module keeps
|
||||||
|
the normalization and centroid contract on the server so web and desktop
|
||||||
|
clients render the same location semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.services.ip_location import IpLocation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GeoLocationMetadata:
|
||||||
|
country: str
|
||||||
|
country_code: str
|
||||||
|
region_code: str
|
||||||
|
longitude: float | None
|
||||||
|
latitude: float | None
|
||||||
|
accuracy_level: str
|
||||||
|
|
||||||
|
|
||||||
|
COUNTRY_ALIASES = {
|
||||||
|
"中国": "China",
|
||||||
|
"China": "China",
|
||||||
|
"Mainland China": "China",
|
||||||
|
"中国香港": "China",
|
||||||
|
"中国澳门": "China",
|
||||||
|
"中国台湾": "China",
|
||||||
|
"美国": "United States",
|
||||||
|
"United States": "United States",
|
||||||
|
"United States of America": "United States",
|
||||||
|
"USA": "United States",
|
||||||
|
"土耳其": "Türkiye",
|
||||||
|
"Turkey": "Türkiye",
|
||||||
|
"Türkiye": "Türkiye",
|
||||||
|
}
|
||||||
|
|
||||||
|
COUNTRY_CODES = {
|
||||||
|
"China": "CN",
|
||||||
|
"United States": "US",
|
||||||
|
"Netherlands": "NL",
|
||||||
|
"Türkiye": "TR",
|
||||||
|
"Australia": "AU",
|
||||||
|
"Japan": "JP",
|
||||||
|
"Singapore": "SG",
|
||||||
|
"Germany": "DE",
|
||||||
|
"France": "FR",
|
||||||
|
"United Kingdom": "GB",
|
||||||
|
"Canada": "CA",
|
||||||
|
"India": "IN",
|
||||||
|
"Russia": "RU",
|
||||||
|
}
|
||||||
|
|
||||||
|
COUNTRY_CENTROIDS = {
|
||||||
|
"China": (104.1954, 35.8617),
|
||||||
|
"United States": (-95.7129, 37.0902),
|
||||||
|
"Netherlands": (5.2913, 52.1326),
|
||||||
|
"Türkiye": (35.2433, 38.9637),
|
||||||
|
"Australia": (133.7751, -25.2744),
|
||||||
|
"Japan": (138.2529, 36.2048),
|
||||||
|
"Singapore": (103.8198, 1.3521),
|
||||||
|
"Germany": (10.4515, 51.1657),
|
||||||
|
"France": (2.2137, 46.2276),
|
||||||
|
"United Kingdom": (-3.436, 55.3781),
|
||||||
|
"Canada": (-106.3468, 56.1304),
|
||||||
|
"India": (78.9629, 20.5937),
|
||||||
|
"Russia": (105.3188, 61.524),
|
||||||
|
}
|
||||||
|
|
||||||
|
CHINA_REGION_METADATA = {
|
||||||
|
"北京市": ("CN-BJ", 116.4074, 39.9042),
|
||||||
|
"天津市": ("CN-TJ", 117.2008, 39.0842),
|
||||||
|
"河北省": ("CN-HE", 114.5025, 38.0455),
|
||||||
|
"山西省": ("CN-SX", 112.5492, 37.857),
|
||||||
|
"内蒙古自治区": ("CN-NM", 111.6708, 40.8183),
|
||||||
|
"辽宁省": ("CN-LN", 123.4315, 41.8057),
|
||||||
|
"吉林省": ("CN-JL", 125.3245, 43.8868),
|
||||||
|
"黑龙江省": ("CN-HL", 126.6424, 45.7567),
|
||||||
|
"上海市": ("CN-SH", 121.4737, 31.2304),
|
||||||
|
"江苏省": ("CN-JS", 118.7633, 32.0617),
|
||||||
|
"浙江省": ("CN-ZJ", 120.1551, 30.2741),
|
||||||
|
"安徽省": ("CN-AH", 117.2272, 31.8206),
|
||||||
|
"福建省": ("CN-FJ", 119.2965, 26.0745),
|
||||||
|
"江西省": ("CN-JX", 115.8582, 28.682),
|
||||||
|
"山东省": ("CN-SD", 117.1201, 36.6512),
|
||||||
|
"河南省": ("CN-HA", 113.6254, 34.7466),
|
||||||
|
"湖北省": ("CN-HB", 114.3055, 30.5928),
|
||||||
|
"湖南省": ("CN-HN", 112.9388, 28.2282),
|
||||||
|
"广东省": ("CN-GD", 113.2644, 23.1291),
|
||||||
|
"广西壮族自治区": ("CN-GX", 108.3669, 22.817),
|
||||||
|
"海南省": ("CN-HI", 110.3312, 20.0311),
|
||||||
|
"重庆": ("CN-CQ", 106.5516, 29.563),
|
||||||
|
"重庆市": ("CN-CQ", 106.5516, 29.563),
|
||||||
|
"四川省": ("CN-SC", 104.0665, 30.5723),
|
||||||
|
"贵州省": ("CN-GZ", 106.6302, 26.647),
|
||||||
|
"云南省": ("CN-YN", 102.8329, 24.8801),
|
||||||
|
"西藏自治区": ("CN-XZ", 91.1322, 29.6604),
|
||||||
|
"陕西省": ("CN-SN", 108.9398, 34.3416),
|
||||||
|
"甘肃省": ("CN-GS", 103.8343, 36.0611),
|
||||||
|
"青海省": ("CN-QH", 101.7782, 36.6171),
|
||||||
|
"宁夏回族自治区": ("CN-NX", 106.2309, 38.4872),
|
||||||
|
"新疆维吾尔自治区": ("CN-XJ", 87.6168, 43.8256),
|
||||||
|
"台湾省": ("CN-TW", 121.5654, 25.033),
|
||||||
|
"香港特别行政区": ("CN-HK", 114.1694, 22.3193),
|
||||||
|
"澳门特别行政区": ("CN-MO", 113.5439, 22.1987),
|
||||||
|
}
|
||||||
|
|
||||||
|
CITY_CENTROIDS = {
|
||||||
|
"南京": (118.7969, 32.0603),
|
||||||
|
"南京市": (118.7969, 32.0603),
|
||||||
|
"San Jose": (-121.8863, 37.3382),
|
||||||
|
"South Holland": (4.493, 52.0208),
|
||||||
|
"Istanbul": (28.9784, 41.0082),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_geo_location_metadata(location: IpLocation) -> GeoLocationMetadata:
|
||||||
|
if location.location in {"局域网", "本机"}:
|
||||||
|
return GeoLocationMetadata(
|
||||||
|
country="",
|
||||||
|
country_code="PRIVATE",
|
||||||
|
region_code="PRIVATE",
|
||||||
|
longitude=None,
|
||||||
|
latitude=None,
|
||||||
|
accuracy_level="private",
|
||||||
|
)
|
||||||
|
|
||||||
|
country = COUNTRY_ALIASES.get(location.country, location.country)
|
||||||
|
country_code = COUNTRY_CODES.get(country, "")
|
||||||
|
|
||||||
|
city_coordinate = CITY_CENTROIDS.get(location.city)
|
||||||
|
if city_coordinate:
|
||||||
|
return GeoLocationMetadata(
|
||||||
|
country=country,
|
||||||
|
country_code=country_code,
|
||||||
|
region_code="",
|
||||||
|
longitude=city_coordinate[0],
|
||||||
|
latitude=city_coordinate[1],
|
||||||
|
accuracy_level="city",
|
||||||
|
)
|
||||||
|
|
||||||
|
region_metadata = CHINA_REGION_METADATA.get(location.province)
|
||||||
|
if country in {"China", "中国"} and region_metadata:
|
||||||
|
region_code, longitude, latitude = region_metadata
|
||||||
|
return GeoLocationMetadata(
|
||||||
|
country="China",
|
||||||
|
country_code="CN",
|
||||||
|
region_code=region_code,
|
||||||
|
longitude=longitude,
|
||||||
|
latitude=latitude,
|
||||||
|
accuracy_level="region",
|
||||||
|
)
|
||||||
|
|
||||||
|
country_coordinate = COUNTRY_CENTROIDS.get(country)
|
||||||
|
if country_coordinate:
|
||||||
|
return GeoLocationMetadata(
|
||||||
|
country=country,
|
||||||
|
country_code=country_code,
|
||||||
|
region_code="",
|
||||||
|
longitude=country_coordinate[0],
|
||||||
|
latitude=country_coordinate[1],
|
||||||
|
accuracy_level="country",
|
||||||
|
)
|
||||||
|
|
||||||
|
return GeoLocationMetadata(
|
||||||
|
country=country,
|
||||||
|
country_code=country_code,
|
||||||
|
region_code="",
|
||||||
|
longitude=None,
|
||||||
|
latitude=None,
|
||||||
|
accuracy_level="unknown",
|
||||||
|
)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Bounded third-party coordinate fallback for public source IPs.
|
||||||
|
|
||||||
|
IPAddress.my identifies IP2Location.io as its data provider. We use the
|
||||||
|
provider's documented JSON API instead of scraping the public HTML page.
|
||||||
|
Only globally routable addresses are eligible, and results are cached so the
|
||||||
|
monitoring UI does not turn into a per-refresh third-party lookup fan-out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.geo_location_metadata import GeoLocationMetadata
|
||||||
|
from app.services.ip_location import IpLocation
|
||||||
|
|
||||||
|
logger = logging.getLogger("ctms.ip_geolocation_fallback")
|
||||||
|
|
||||||
|
_API_URL = "https://api.ip2location.io/"
|
||||||
|
_MAX_RESPONSE_BYTES = 64 * 1024
|
||||||
|
_MAX_CACHE_ENTRIES = 4096
|
||||||
|
_NEGATIVE_CACHE_SECONDS = 3600
|
||||||
|
_cache: dict[str, tuple[float, "ExternalIpLocation | None"]] = {}
|
||||||
|
_cache_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any, limit: int = 160) -> str:
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
return "" if candidate in {"", "-", "0"} else candidate[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinate(value: Any, *, minimum: float, maximum: float) -> float | None:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return number if math.isfinite(number) and minimum <= number <= maximum else None
|
||||||
|
|
||||||
|
|
||||||
|
def _global_ip(value: str | None) -> str | None:
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address((value or "").strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return str(address) if address.is_global else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExternalIpLocation:
|
||||||
|
ip_address: str
|
||||||
|
country: str
|
||||||
|
country_code: str
|
||||||
|
region: str
|
||||||
|
city: str
|
||||||
|
isp: str
|
||||||
|
longitude: float
|
||||||
|
latitude: float
|
||||||
|
|
||||||
|
def merge_ip_location(self, local: IpLocation) -> IpLocation:
|
||||||
|
country = self.country or local.country
|
||||||
|
province = self.region or local.province
|
||||||
|
city = self.city or local.city
|
||||||
|
isp = self.isp or local.isp
|
||||||
|
location = " / ".join(part for part in [country, province, city, isp] if part) or local.location
|
||||||
|
return IpLocation(location=location, country=country, province=province, city=city, isp=isp)
|
||||||
|
|
||||||
|
def to_metadata(self) -> GeoLocationMetadata:
|
||||||
|
return GeoLocationMetadata(
|
||||||
|
country=self.country,
|
||||||
|
country_code=self.country_code,
|
||||||
|
region_code="",
|
||||||
|
longitude=self.longitude,
|
||||||
|
latitude=self.latitude,
|
||||||
|
accuracy_level="city" if self.city else "country",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(expected_ip: str, payload: Any) -> ExternalIpLocation | None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
response_ip = _global_ip(_text(payload.get("ip"), 45))
|
||||||
|
if response_ip != expected_ip:
|
||||||
|
return None
|
||||||
|
latitude = _coordinate(payload.get("latitude"), minimum=-90, maximum=90)
|
||||||
|
longitude = _coordinate(payload.get("longitude"), minimum=-180, maximum=180)
|
||||||
|
if latitude is None or longitude is None:
|
||||||
|
return None
|
||||||
|
country_code = _text(payload.get("country_code"), 2).upper()
|
||||||
|
if len(country_code) != 2:
|
||||||
|
country_code = ""
|
||||||
|
return ExternalIpLocation(
|
||||||
|
ip_address=expected_ip,
|
||||||
|
country=_text(payload.get("country_name"), 100),
|
||||||
|
country_code=country_code,
|
||||||
|
region=_text(payload.get("region_name"), 100),
|
||||||
|
city=_text(payload.get("city_name"), 100),
|
||||||
|
isp=_text(payload.get("isp"), 160),
|
||||||
|
longitude=longitude,
|
||||||
|
latitude=latitude,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_one(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
semaphore: asyncio.Semaphore,
|
||||||
|
ip_address: str,
|
||||||
|
) -> ExternalIpLocation | None:
|
||||||
|
async with semaphore:
|
||||||
|
try:
|
||||||
|
response = await client.get(_API_URL, params={"ip": ip_address, "format": "json"})
|
||||||
|
response.raise_for_status()
|
||||||
|
if len(response.content) > _MAX_RESPONSE_BYTES:
|
||||||
|
return None
|
||||||
|
return _parse_response(ip_address, response.json())
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
logger.warning("External IP coordinate fallback unavailable")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_external_ip_locations(ip_addresses: Iterable[str]) -> dict[str, ExternalIpLocation]:
|
||||||
|
if not settings.MONITORING_IP_GEO_FALLBACK_ENABLED or settings.ENV == "test":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
candidates = list(dict.fromkeys(filter(None, (_global_ip(value) for value in ip_addresses))))
|
||||||
|
if not candidates:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
resolved: dict[str, ExternalIpLocation] = {}
|
||||||
|
pending: list[str] = []
|
||||||
|
async with _cache_lock:
|
||||||
|
for ip_address in candidates:
|
||||||
|
cached = _cache.get(ip_address)
|
||||||
|
if cached and cached[0] > now:
|
||||||
|
if cached[1] is not None:
|
||||||
|
resolved[ip_address] = cached[1]
|
||||||
|
continue
|
||||||
|
pending.append(ip_address)
|
||||||
|
|
||||||
|
pending = pending[: settings.MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS]
|
||||||
|
if not pending:
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "CTMS-IP-Coordinate-Fallback/1.0"}
|
||||||
|
api_key = (settings.MONITORING_IP_GEO_FALLBACK_API_KEY or "").strip()
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
timeout = httpx.Timeout(settings.MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS)
|
||||||
|
semaphore = asyncio.Semaphore(min(5, len(pending)))
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, headers=headers) as client:
|
||||||
|
fetched = await asyncio.gather(*(_fetch_one(client, semaphore, item) for item in pending))
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
async with _cache_lock:
|
||||||
|
for ip_address, item in zip(pending, fetched):
|
||||||
|
ttl = settings.MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS if item else _NEGATIVE_CACHE_SECONDS
|
||||||
|
_cache[ip_address] = (now + ttl, item)
|
||||||
|
if item is not None:
|
||||||
|
resolved[ip_address] = item
|
||||||
|
while len(_cache) > _MAX_CACHE_ENTRIES:
|
||||||
|
_cache.pop(next(iter(_cache)))
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def reset_ip_geolocation_fallback_cache() -> None:
|
||||||
|
_cache.clear()
|
||||||
@@ -9,6 +9,7 @@ import ipaddress
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -125,5 +126,6 @@ class Ip2RegionResolver:
|
|||||||
_resolver = Ip2RegionResolver()
|
_resolver = Ip2RegionResolver()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8192)
|
||||||
def resolve_ip_location(ip: str | None) -> IpLocation:
|
def resolve_ip_location(ip: str | None) -> IpLocation:
|
||||||
return _resolver.lookup(ip)
|
return _resolver.lookup(ip)
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""监测数据留存清理任务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.permission_access_log import PermissionAccessLog
|
||||||
|
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||||
|
from app.models.security_access_log import SecurityAccessLog
|
||||||
|
from app.models.source_location_snapshot import SourceLocationSnapshot
|
||||||
|
from app.models.user_login_session import UserLoginSession
|
||||||
|
|
||||||
|
logger = logging.getLogger("ctms.monitoring_retention")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MonitoringRetentionState:
|
||||||
|
running: bool = False
|
||||||
|
last_run_started_at: datetime | None = None
|
||||||
|
last_success_at: datetime | None = None
|
||||||
|
last_error_at: datetime | None = None
|
||||||
|
last_error_type: str | None = None
|
||||||
|
error_count: int = 0
|
||||||
|
last_deleted: dict[str, int] = field(default_factory=dict)
|
||||||
|
total_deleted: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"running": self.running,
|
||||||
|
"access_log_retention_days": settings.MONITORING_ACCESS_LOG_RETENTION_DAYS,
|
||||||
|
"metric_retention_days": settings.MONITORING_METRIC_RETENTION_DAYS,
|
||||||
|
"login_activity_retention_days": settings.USER_LOGIN_ACTIVITY_RETENTION_DAYS,
|
||||||
|
"interval_seconds": settings.MONITORING_RETENTION_INTERVAL_SECONDS,
|
||||||
|
"last_run_started_at": (
|
||||||
|
self.last_run_started_at.isoformat() if self.last_run_started_at else None
|
||||||
|
),
|
||||||
|
"last_success_at": self.last_success_at.isoformat() if self.last_success_at else None,
|
||||||
|
"last_error_at": self.last_error_at.isoformat() if self.last_error_at else None,
|
||||||
|
"last_error_type": self.last_error_type,
|
||||||
|
"error_count": self.error_count,
|
||||||
|
"last_deleted": dict(self.last_deleted),
|
||||||
|
"total_deleted": dict(self.total_deleted),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_state = MonitoringRetentionState()
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitoring_retention_status() -> dict[str, Any]:
|
||||||
|
return _state.snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def _deleted_count(result: Any) -> int:
|
||||||
|
rowcount = getattr(result, "rowcount", 0)
|
||||||
|
return max(0, int(rowcount or 0))
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_expired_monitoring_data(
|
||||||
|
*, now: datetime | None = None
|
||||||
|
) -> dict[str, int]:
|
||||||
|
reference_time = now or datetime.now(timezone.utc)
|
||||||
|
access_cutoff = reference_time - timedelta(
|
||||||
|
days=settings.MONITORING_ACCESS_LOG_RETENTION_DAYS
|
||||||
|
)
|
||||||
|
metric_cutoff = reference_time - timedelta(
|
||||||
|
days=settings.MONITORING_METRIC_RETENTION_DAYS
|
||||||
|
)
|
||||||
|
login_activity_cutoff = reference_time - timedelta(
|
||||||
|
days=settings.USER_LOGIN_ACTIVITY_RETENTION_DAYS
|
||||||
|
)
|
||||||
|
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
permission_result = await session.execute(
|
||||||
|
delete(PermissionAccessLog).where(PermissionAccessLog.created_at < access_cutoff)
|
||||||
|
)
|
||||||
|
security_result = await session.execute(
|
||||||
|
delete(SecurityAccessLog).where(SecurityAccessLog.created_at < access_cutoff)
|
||||||
|
)
|
||||||
|
metric_result = await session.execute(
|
||||||
|
delete(PermissionMetricSnapshot).where(
|
||||||
|
PermissionMetricSnapshot.bucket_time < metric_cutoff
|
||||||
|
)
|
||||||
|
)
|
||||||
|
source_location_result = await session.execute(
|
||||||
|
delete(SourceLocationSnapshot).where(
|
||||||
|
SourceLocationSnapshot.bucket_time < access_cutoff
|
||||||
|
)
|
||||||
|
)
|
||||||
|
login_session_result = await session.execute(
|
||||||
|
delete(UserLoginSession).where(UserLoginSession.last_seen_at < login_activity_cutoff)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"permission_access_logs": _deleted_count(permission_result),
|
||||||
|
"security_access_logs": _deleted_count(security_result),
|
||||||
|
"permission_metric_snapshots": _deleted_count(metric_result),
|
||||||
|
"source_location_snapshots": _deleted_count(source_location_result),
|
||||||
|
"user_login_sessions": _deleted_count(login_session_result),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_monitoring_retention_once(
|
||||||
|
*, now: datetime | None = None
|
||||||
|
) -> dict[str, int]:
|
||||||
|
_state.last_run_started_at = now or datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
deleted = await purge_expired_monitoring_data(now=now)
|
||||||
|
except Exception as exc:
|
||||||
|
_state.last_error_at = datetime.now(timezone.utc)
|
||||||
|
_state.last_error_type = type(exc).__name__
|
||||||
|
_state.error_count += 1
|
||||||
|
raise
|
||||||
|
|
||||||
|
_state.last_success_at = datetime.now(timezone.utc)
|
||||||
|
_state.last_deleted = deleted
|
||||||
|
for key, count in deleted.items():
|
||||||
|
_state.total_deleted[key] = _state.total_deleted.get(key, 0) + count
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def run_monitoring_retention(stop_event: asyncio.Event) -> None:
|
||||||
|
logger.info("Monitoring retention task started")
|
||||||
|
_state.running = True
|
||||||
|
try:
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
deleted = await run_monitoring_retention_once()
|
||||||
|
if any(deleted.values()):
|
||||||
|
logger.info("Purged expired monitoring data: %s", deleted)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to purge expired monitoring data")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
stop_event.wait(),
|
||||||
|
timeout=settings.MONITORING_RETENTION_INTERVAL_SECONDS,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
_state.running = False
|
||||||
|
logger.info("Monitoring retention task stopped")
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Resolve the monitoring deployment's public network location.
|
||||||
|
|
||||||
|
The map server marker is derived from the deployment's public IP instead of a
|
||||||
|
frontend or configuration coordinate. An explicit public IP is accepted for
|
||||||
|
restricted networks; otherwise the public frontend hostname and, finally, a
|
||||||
|
small set of public-IP discovery endpoints are used. Results are cached so the
|
||||||
|
monitoring API never performs per-request network discovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.geo_location_metadata import resolve_geo_location_metadata
|
||||||
|
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
|
||||||
|
from app.services.ip_location import resolve_ip_location
|
||||||
|
|
||||||
|
logger = logging.getLogger("ctms.monitoring_server_location")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MonitoringServerLocation:
|
||||||
|
name: str
|
||||||
|
longitude: float
|
||||||
|
latitude: float
|
||||||
|
accuracy_level: str
|
||||||
|
resolution_source: str
|
||||||
|
|
||||||
|
def to_public_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"longitude": self.longitude,
|
||||||
|
"latitude": self.latitude,
|
||||||
|
"accuracy_level": self.accuracy_level,
|
||||||
|
"resolution_source": self.resolution_source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_cached_location: MonitoringServerLocation | None = None
|
||||||
|
_cache_initialized = False
|
||||||
|
_cache_expires_at = 0.0
|
||||||
|
_cache_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_public_ip(value: str | None) -> str | None:
|
||||||
|
candidate = (value or "").strip().splitlines()[0] if (value or "").strip() else ""
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return str(address) if address.is_global else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_frontend_public_ip() -> str | None:
|
||||||
|
hostname = urlparse(settings.FRONTEND_PUBLIC_URL).hostname
|
||||||
|
if not hostname:
|
||||||
|
return None
|
||||||
|
literal_ip = _normalize_public_ip(hostname)
|
||||||
|
if literal_ip:
|
||||||
|
return literal_ip
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
records = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||||
|
except (OSError, socket.gaierror):
|
||||||
|
return None
|
||||||
|
candidates = []
|
||||||
|
for _, _, _, _, socket_address in records:
|
||||||
|
candidate = _normalize_public_ip(str(socket_address[0]))
|
||||||
|
if candidate and candidate not in candidates:
|
||||||
|
candidates.append(candidate)
|
||||||
|
return next((item for item in candidates if ":" not in item), candidates[0] if candidates else None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _discover_public_ip() -> tuple[str | None, str]:
|
||||||
|
configured_ip = _normalize_public_ip(settings.MONITORING_SERVER_PUBLIC_IP)
|
||||||
|
if configured_ip:
|
||||||
|
return configured_ip, "configured_public_ip"
|
||||||
|
|
||||||
|
frontend_ip = await _resolve_frontend_public_ip()
|
||||||
|
if frontend_ip:
|
||||||
|
return frontend_ip, "frontend_dns"
|
||||||
|
|
||||||
|
if settings.ENV == "test":
|
||||||
|
return None, "unavailable"
|
||||||
|
|
||||||
|
urls = [
|
||||||
|
item.strip()
|
||||||
|
for item in settings.MONITORING_PUBLIC_IP_DISCOVERY_URLS.split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
timeout = httpx.Timeout(settings.MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
||||||
|
for url in urls:
|
||||||
|
try:
|
||||||
|
response = await client.get(url, headers={"Accept": "text/plain"})
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
logger.warning("Public IP discovery endpoint unavailable", extra={"endpoint": url})
|
||||||
|
continue
|
||||||
|
discovered_ip = _normalize_public_ip(response.text[:128])
|
||||||
|
if discovered_ip:
|
||||||
|
return discovered_ip, "public_ip_discovery"
|
||||||
|
return None, "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_monitoring_server_location(*, force: bool = False) -> MonitoringServerLocation | None:
|
||||||
|
global _cached_location, _cache_initialized, _cache_expires_at
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if not force and _cache_initialized and now < _cache_expires_at:
|
||||||
|
return _cached_location
|
||||||
|
|
||||||
|
async with _cache_lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
if not force and _cache_initialized and now < _cache_expires_at:
|
||||||
|
return _cached_location
|
||||||
|
|
||||||
|
public_ip, resolution_source = await _discover_public_ip()
|
||||||
|
location = None
|
||||||
|
if public_ip:
|
||||||
|
ip_location = resolve_ip_location(public_ip)
|
||||||
|
metadata = resolve_geo_location_metadata(ip_location)
|
||||||
|
if metadata.longitude is None or metadata.latitude is None:
|
||||||
|
external_location = (await resolve_external_ip_locations([public_ip])).get(public_ip)
|
||||||
|
if external_location is not None:
|
||||||
|
ip_location = external_location.merge_ip_location(ip_location)
|
||||||
|
metadata = external_location.to_metadata()
|
||||||
|
if metadata.longitude is not None and metadata.latitude is not None:
|
||||||
|
country = metadata.country or ip_location.country
|
||||||
|
name = " / ".join(
|
||||||
|
part for part in [country, ip_location.province, ip_location.city] if part
|
||||||
|
) or "公网服务器"
|
||||||
|
location = MonitoringServerLocation(
|
||||||
|
name=name,
|
||||||
|
longitude=metadata.longitude,
|
||||||
|
latitude=metadata.latitude,
|
||||||
|
accuracy_level=metadata.accuracy_level,
|
||||||
|
resolution_source=resolution_source,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Monitoring server location resolved",
|
||||||
|
extra={"resolution_source": resolution_source, "accuracy_level": metadata.accuracy_level},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Deployment public IP has no usable geo coordinates")
|
||||||
|
else:
|
||||||
|
logger.warning("Unable to discover the deployment public IP")
|
||||||
|
|
||||||
|
_cached_location = location
|
||||||
|
_cache_initialized = True
|
||||||
|
cache_seconds = settings.MONITORING_SERVER_LOCATION_CACHE_SECONDS if location else 300
|
||||||
|
_cache_expires_at = now + cache_seconds
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
def reset_monitoring_server_location_cache() -> None:
|
||||||
|
"""Reset process-local state for configuration reloads and tests."""
|
||||||
|
global _cached_location, _cache_initialized, _cache_expires_at
|
||||||
|
_cached_location = None
|
||||||
|
_cache_initialized = False
|
||||||
|
_cache_expires_at = 0.0
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Server-side materialization for time and state driven business reminders."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.study_member import StudyMember
|
||||||
|
from app.models.user import User, UserStatus
|
||||||
|
from app.services.project_reminder_service import sync_project_reminders
|
||||||
|
|
||||||
|
logger = logging.getLogger("ctms.notifications")
|
||||||
|
NOTIFICATION_SYNC_LOCK_KEY = 0x43544D53 # "CTMS"
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_all_project_reminders_once() -> int:
|
||||||
|
"""Synchronize every active project member once, with a cross-worker advisory lock."""
|
||||||
|
async with SessionLocal() as lock_session:
|
||||||
|
acquired = bool(await lock_session.scalar(
|
||||||
|
text("SELECT pg_try_advisory_lock(:lock_key)"),
|
||||||
|
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
|
||||||
|
))
|
||||||
|
if not acquired:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
memberships = (await lock_session.execute(
|
||||||
|
select(StudyMember.study_id, StudyMember.user_id)
|
||||||
|
.join(User, User.id == StudyMember.user_id)
|
||||||
|
.where(
|
||||||
|
StudyMember.is_active.is_(True),
|
||||||
|
User.status == UserStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
await lock_session.commit()
|
||||||
|
synced = 0
|
||||||
|
for study_id, user_id in memberships:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
try:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
continue
|
||||||
|
await sync_project_reminders(session, study_id, user)
|
||||||
|
synced += 1
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception(
|
||||||
|
"Failed to synchronize reminders for study=%s user=%s",
|
||||||
|
study_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return synced
|
||||||
|
finally:
|
||||||
|
await lock_session.execute(
|
||||||
|
text("SELECT pg_advisory_unlock(:lock_key)"),
|
||||||
|
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_notification_sync_job(stop_event: asyncio.Event) -> None:
|
||||||
|
logger.info("Notification synchronization task started")
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
synced = await sync_all_project_reminders_once()
|
||||||
|
logger.debug("Synchronized reminders for %s project memberships", synced)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Notification synchronization task failed")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
stop_event.wait(),
|
||||||
|
timeout=settings.NOTIFICATION_SYNC_INTERVAL_SECONDS,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
logger.info("Notification synchronization task stopped")
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.schemas.notification import GeneralNotificationFeed
|
||||||
|
|
||||||
|
|
||||||
|
async def _requeue_desktop_delivery(db: AsyncSession, notification_id: uuid.UUID) -> None:
|
||||||
|
"""Allow an escalated/reopened reminder to be delivered again by the desktop channel."""
|
||||||
|
await db.execute(
|
||||||
|
update(DesktopNotificationDelivery)
|
||||||
|
.where(DesktopNotificationDelivery.notification_id == notification_id)
|
||||||
|
.values(
|
||||||
|
claim_token=None,
|
||||||
|
claimed_at=None,
|
||||||
|
delivered_at=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_recipient_notifications(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
recipient_ids: Iterable[uuid.UUID],
|
||||||
|
category: str,
|
||||||
|
priority: str,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
action_path: str | None,
|
||||||
|
source_type: str,
|
||||||
|
source_id: str,
|
||||||
|
dedupe_key: str,
|
||||||
|
source_version: str | None = None,
|
||||||
|
requires_action: bool = True,
|
||||||
|
due_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
recipients = set(recipient_ids)
|
||||||
|
if not recipients:
|
||||||
|
return
|
||||||
|
existing = set((await db.scalars(
|
||||||
|
select(Notification.recipient_id).where(
|
||||||
|
Notification.recipient_id.in_(recipients),
|
||||||
|
Notification.dedupe_key == dedupe_key,
|
||||||
|
)
|
||||||
|
)).all())
|
||||||
|
for recipient_id in recipients - existing:
|
||||||
|
db.add(Notification(
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
category=category,
|
||||||
|
priority=priority,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
action_path=action_path,
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
source_version=source_version,
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
requires_action=requires_action,
|
||||||
|
due_at=due_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_state_notification(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
recipient_id: uuid.UUID,
|
||||||
|
category: str,
|
||||||
|
priority: str,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
action_path: str,
|
||||||
|
source_type: str,
|
||||||
|
source_id: str,
|
||||||
|
source_version: str,
|
||||||
|
active: bool,
|
||||||
|
due_at: datetime | None = None,
|
||||||
|
requires_action: bool = True,
|
||||||
|
dedupe_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Synchronize one actionable business state into a recipient reminder."""
|
||||||
|
dedupe_key = dedupe_key or f"state:{source_type}:{source_id}"
|
||||||
|
item = await db.scalar(select(Notification).where(
|
||||||
|
Notification.recipient_id == recipient_id,
|
||||||
|
Notification.dedupe_key == dedupe_key,
|
||||||
|
))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if not active:
|
||||||
|
if item and item.resolved_at is None:
|
||||||
|
item.resolved_at = now
|
||||||
|
item.read_at = item.read_at or now
|
||||||
|
return
|
||||||
|
if item is None:
|
||||||
|
db.add(Notification(
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
category=category,
|
||||||
|
priority=priority,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
action_path=action_path,
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
source_version=source_version,
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
requires_action=requires_action,
|
||||||
|
due_at=due_at,
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
should_notify_again = item.resolved_at is not None or item.source_version != source_version
|
||||||
|
item.study_id = study_id
|
||||||
|
item.category = category
|
||||||
|
item.priority = priority
|
||||||
|
item.title = title
|
||||||
|
item.message = message
|
||||||
|
item.action_path = action_path
|
||||||
|
item.source_version = source_version
|
||||||
|
item.requires_action = requires_action
|
||||||
|
item.due_at = due_at
|
||||||
|
if should_notify_again:
|
||||||
|
item.read_at = None
|
||||||
|
item.created_at = now
|
||||||
|
await _requeue_desktop_delivery(db, item.id)
|
||||||
|
item.resolved_at = None
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_aggregate_notification(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
recipient_id: uuid.UUID,
|
||||||
|
category: str,
|
||||||
|
priority: str,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
action_path: str,
|
||||||
|
source_type: str,
|
||||||
|
source_id: str,
|
||||||
|
count: int,
|
||||||
|
due_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
dedupe_key = f"aggregate:{source_type}:{source_id}"
|
||||||
|
item = await db.scalar(select(Notification).where(
|
||||||
|
Notification.recipient_id == recipient_id,
|
||||||
|
Notification.dedupe_key == dedupe_key,
|
||||||
|
))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if count <= 0:
|
||||||
|
if item and item.resolved_at is None:
|
||||||
|
item.resolved_at = now
|
||||||
|
item.read_at = item.read_at or now
|
||||||
|
return
|
||||||
|
version = str(count)
|
||||||
|
if item is None:
|
||||||
|
db.add(Notification(
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
category=category,
|
||||||
|
priority=priority,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
action_path=action_path,
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
source_version=version,
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
requires_action=True,
|
||||||
|
due_at=due_at,
|
||||||
|
))
|
||||||
|
return
|
||||||
|
previous_count = int(item.source_version or 0)
|
||||||
|
item.category = category
|
||||||
|
item.priority = priority
|
||||||
|
item.title = title
|
||||||
|
item.message = message
|
||||||
|
item.action_path = action_path
|
||||||
|
item.source_version = version
|
||||||
|
item.due_at = due_at
|
||||||
|
if item.resolved_at is not None or count > previous_count:
|
||||||
|
item.read_at = None
|
||||||
|
item.created_at = now
|
||||||
|
await _requeue_desktop_delivery(db, item.id)
|
||||||
|
item.resolved_at = None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_missing_recipient_sources(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
recipient_id: uuid.UUID,
|
||||||
|
source_type: str,
|
||||||
|
active_source_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
filters = [
|
||||||
|
Notification.study_id == study_id,
|
||||||
|
Notification.recipient_id == recipient_id,
|
||||||
|
Notification.source_type == source_type,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
]
|
||||||
|
if active_source_ids:
|
||||||
|
filters.append(Notification.source_id.not_in(active_source_ids))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
await db.execute(
|
||||||
|
update(Notification)
|
||||||
|
.where(*filters)
|
||||||
|
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_source_notifications(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
source_type: str,
|
||||||
|
source_id: str,
|
||||||
|
recipient_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
filters = [
|
||||||
|
Notification.source_type == source_type,
|
||||||
|
Notification.source_id == source_id,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
]
|
||||||
|
if recipient_id is not None:
|
||||||
|
filters.append(Notification.recipient_id == recipient_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
await db.execute(
|
||||||
|
update(Notification)
|
||||||
|
.where(*filters)
|
||||||
|
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_recipient_study_notifications(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
recipient_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
await db.execute(
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.study_id == study_id,
|
||||||
|
Notification.recipient_id == recipient_id,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_feed(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
recipient_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 10,
|
||||||
|
category: str | None = None,
|
||||||
|
unread_only: bool = False,
|
||||||
|
requires_action: bool | None = None,
|
||||||
|
) -> GeneralNotificationFeed:
|
||||||
|
active_filter = (
|
||||||
|
Notification.study_id == study_id,
|
||||||
|
Notification.recipient_id == recipient_id,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
)
|
||||||
|
unread_count = int(await db.scalar(
|
||||||
|
select(func.count(Notification.id)).where(*active_filter, Notification.read_at.is_(None))
|
||||||
|
) or 0)
|
||||||
|
|
||||||
|
filters = [
|
||||||
|
Notification.study_id == study_id,
|
||||||
|
Notification.recipient_id == recipient_id,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
]
|
||||||
|
if category:
|
||||||
|
filters.append(Notification.category == category)
|
||||||
|
if unread_only:
|
||||||
|
filters.append(Notification.read_at.is_(None))
|
||||||
|
if requires_action is not None:
|
||||||
|
filters.append(Notification.requires_action.is_(requires_action))
|
||||||
|
total_count = int(await db.scalar(
|
||||||
|
select(func.count(Notification.id)).where(*filters)
|
||||||
|
) or 0)
|
||||||
|
items = (await db.scalars(
|
||||||
|
select(Notification)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(
|
||||||
|
Notification.read_at.is_not(None),
|
||||||
|
Notification.created_at.desc(),
|
||||||
|
)
|
||||||
|
.offset(max(0, skip))
|
||||||
|
.limit(max(1, min(limit, 100)))
|
||||||
|
)).all()
|
||||||
|
return GeneralNotificationFeed(
|
||||||
|
unread_count=unread_count,
|
||||||
|
total_count=total_count,
|
||||||
|
items=list(items),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_read(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
recipient_id: uuid.UUID,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
) -> Notification:
|
||||||
|
item = await db.scalar(select(Notification).where(
|
||||||
|
Notification.id == notification_id,
|
||||||
|
Notification.study_id == study_id,
|
||||||
|
Notification.recipient_id == recipient_id,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
))
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
|
||||||
|
if item.read_at is None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
item.read_at = now
|
||||||
|
if not item.requires_action:
|
||||||
|
item.resolved_at = now
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_all_read(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
recipient_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
await db.execute(
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.study_id == study_id,
|
||||||
|
Notification.recipient_id == recipient_id,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
Notification.read_at.is_(None),
|
||||||
|
Notification.requires_action.is_(False),
|
||||||
|
)
|
||||||
|
.values(read_at=now, resolved_at=now)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.study_id == study_id,
|
||||||
|
Notification.recipient_id == recipient_id,
|
||||||
|
Notification.resolved_at.is_(None),
|
||||||
|
Notification.read_at.is_(None),
|
||||||
|
Notification.requires_action.is_(True),
|
||||||
|
)
|
||||||
|
.values(read_at=now)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.collaboration import (
|
||||||
|
CollaborationCallbackReceipt,
|
||||||
|
CollaborationFile,
|
||||||
|
CollaborationRevision,
|
||||||
|
CollaborationSession,
|
||||||
|
CollaborationShareLink,
|
||||||
|
)
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
|
||||||
|
from app.services import collaboration_service, onlyoffice_service
|
||||||
|
|
||||||
|
|
||||||
|
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
|
||||||
|
fingerprint = f"{settings.ONLYOFFICE_INSTANCE_ID or ''}:collaboration:{file_id}:{generation}"
|
||||||
|
return f"ctms-collab-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _content_url(session_id: uuid.UUID) -> str:
|
||||||
|
return (
|
||||||
|
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||||
|
f"/internal/onlyoffice/collaboration/sessions/{session_id}/content"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _callback_url(session_id: uuid.UUID) -> str:
|
||||||
|
return (
|
||||||
|
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||||
|
f"/internal/onlyoffice/collaboration/sessions/{session_id}/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _active_session(
|
||||||
|
db: AsyncSession, item: CollaborationFile, user_id: uuid.UUID
|
||||||
|
) -> CollaborationSession:
|
||||||
|
session = await db.scalar(
|
||||||
|
select(CollaborationSession).where(
|
||||||
|
CollaborationSession.file_id == item.id,
|
||||||
|
CollaborationSession.generation == item.generation,
|
||||||
|
).order_by(CollaborationSession.created_at.desc())
|
||||||
|
)
|
||||||
|
if session:
|
||||||
|
if session.status != "ACTIVE":
|
||||||
|
session.status = "ACTIVE"
|
||||||
|
session.closed_at = None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
return session
|
||||||
|
if not item.current_revision_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
|
||||||
|
session = CollaborationSession(
|
||||||
|
file_id=item.id,
|
||||||
|
base_revision_id=item.current_revision_id,
|
||||||
|
document_key=collaboration_document_key(item.id, item.generation),
|
||||||
|
generation=item.generation,
|
||||||
|
started_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
async def build_editor_config(
|
||||||
|
db: AsyncSession, item: CollaborationFile, user
|
||||||
|
) -> CollaborationEditorConfigRead:
|
||||||
|
await onlyoffice_service.ensure_onlyoffice_available()
|
||||||
|
revision = await db.get(CollaborationRevision, item.current_revision_id)
|
||||||
|
if not revision or not Path(revision.file_uri).exists():
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
|
||||||
|
can_edit = await collaboration_service.can_edit_file(db, item, user)
|
||||||
|
can_request_edit = await collaboration_service.can_request_edit_file(db, item, user)
|
||||||
|
can_download = await collaboration_service.can_export_file(db, item, user)
|
||||||
|
can_save_as = can_download and await collaboration_service.can_create_file(db, item, user)
|
||||||
|
session = await _active_session(db, item, user.id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"type": "desktop",
|
||||||
|
"documentType": item.file_type,
|
||||||
|
"document": {
|
||||||
|
"fileType": item.extension,
|
||||||
|
"key": session.document_key,
|
||||||
|
"title": item.title,
|
||||||
|
"url": _content_url(session.id),
|
||||||
|
"permissions": {
|
||||||
|
"chat": False,
|
||||||
|
"copy": can_download,
|
||||||
|
"comment": can_edit,
|
||||||
|
"download": can_download,
|
||||||
|
# In view mode ONLYOFFICE displays "Edit current file" only
|
||||||
|
# when edit=true and onRequestEditRights is registered. CTMS
|
||||||
|
# handles that event as an approval request, not an escalation.
|
||||||
|
"edit": can_edit or can_request_edit,
|
||||||
|
"fillForms": False,
|
||||||
|
"modifyContentControl": can_edit,
|
||||||
|
"modifyFilter": can_edit,
|
||||||
|
"print": can_download,
|
||||||
|
"protect": False,
|
||||||
|
"review": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"editorConfig": {
|
||||||
|
"callbackUrl": _callback_url(session.id),
|
||||||
|
"coEditing": {"mode": "fast", "change": False},
|
||||||
|
"customization": {
|
||||||
|
"autosave": True,
|
||||||
|
"chat": False,
|
||||||
|
"comments": can_edit,
|
||||||
|
"forcesave": can_edit,
|
||||||
|
"help": False,
|
||||||
|
"plugins": False,
|
||||||
|
},
|
||||||
|
"lang": "zh-CN",
|
||||||
|
"mode": "edit" if can_edit else "view",
|
||||||
|
"user": {"id": str(user.id), "name": user.full_name},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config["token"] = jwt.encode(
|
||||||
|
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
|
||||||
|
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
return CollaborationEditorConfigRead(
|
||||||
|
file_id=item.id,
|
||||||
|
file_name=item.title,
|
||||||
|
access_mode="edit" if can_edit else "view",
|
||||||
|
can_save_as=can_save_as,
|
||||||
|
can_download=can_download,
|
||||||
|
can_request_edit=can_request_edit,
|
||||||
|
expires_at=expires_at,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_shared_editor_config(
|
||||||
|
db: AsyncSession,
|
||||||
|
item: CollaborationFile,
|
||||||
|
link: CollaborationShareLink,
|
||||||
|
*,
|
||||||
|
client_id: str,
|
||||||
|
display_name: str,
|
||||||
|
) -> CollaborationEditorConfigRead:
|
||||||
|
await onlyoffice_service.ensure_onlyoffice_available()
|
||||||
|
revision = await db.get(CollaborationRevision, item.current_revision_id)
|
||||||
|
if not revision or not Path(revision.file_uri).exists():
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件内容不存在")
|
||||||
|
can_edit = link.access_mode == "EDIT"
|
||||||
|
session = await _active_session(db, item, item.owner_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
|
||||||
|
if link.expires_at and link.expires_at < expires_at:
|
||||||
|
expires_at = link.expires_at
|
||||||
|
external_user_id = f"share-{link.id.hex[:12]}-{client_id[:32]}"
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"type": "desktop",
|
||||||
|
"documentType": item.file_type,
|
||||||
|
"document": {
|
||||||
|
"fileType": item.extension,
|
||||||
|
"key": session.document_key,
|
||||||
|
"title": item.title,
|
||||||
|
"url": _content_url(session.id),
|
||||||
|
"permissions": {
|
||||||
|
"chat": False,
|
||||||
|
"copy": item.allow_export,
|
||||||
|
"comment": can_edit,
|
||||||
|
"download": item.allow_export,
|
||||||
|
"edit": can_edit,
|
||||||
|
"fillForms": False,
|
||||||
|
"modifyContentControl": can_edit,
|
||||||
|
"modifyFilter": can_edit,
|
||||||
|
"print": item.allow_export,
|
||||||
|
"protect": False,
|
||||||
|
"review": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"editorConfig": {
|
||||||
|
"callbackUrl": _callback_url(session.id),
|
||||||
|
"coEditing": {"mode": "fast", "change": False},
|
||||||
|
"customization": {
|
||||||
|
"autosave": can_edit,
|
||||||
|
"chat": False,
|
||||||
|
"comments": can_edit,
|
||||||
|
"forcesave": can_edit,
|
||||||
|
"help": False,
|
||||||
|
"plugins": False,
|
||||||
|
},
|
||||||
|
"lang": "zh-CN",
|
||||||
|
"mode": "edit" if can_edit else "view",
|
||||||
|
"user": {"id": external_user_id, "name": display_name},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config["token"] = jwt.encode(
|
||||||
|
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
|
||||||
|
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
return CollaborationEditorConfigRead(
|
||||||
|
file_id=item.id,
|
||||||
|
file_name=item.title,
|
||||||
|
access_mode="edit" if can_edit else "view",
|
||||||
|
can_save_as=False,
|
||||||
|
can_download=item.allow_export,
|
||||||
|
expires_at=expires_at,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_content(
|
||||||
|
db: AsyncSession, session_id: uuid.UUID, authorization: str | None
|
||||||
|
) -> tuple[CollaborationRevision, CollaborationFile]:
|
||||||
|
session = await db.get(CollaborationSession, session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
||||||
|
onlyoffice_service.validate_outbox_token(authorization, _content_url(session_id))
|
||||||
|
revision = await db.get(CollaborationRevision, session.base_revision_id)
|
||||||
|
item = await db.get(CollaborationFile, session.file_id)
|
||||||
|
if not revision or not item or not Path(revision.file_uri).exists():
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
|
||||||
|
return revision, item
|
||||||
|
|
||||||
|
|
||||||
|
def validate_callback_token(token: str | None, payload: CollaborationCallbackPayload) -> dict[str, Any]:
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少 ONLYOFFICE 回调签名")
|
||||||
|
value = token.strip()
|
||||||
|
if " " in value:
|
||||||
|
scheme, credential = value.split(" ", 1)
|
||||||
|
if scheme.lower() != "bearer":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名格式无效")
|
||||||
|
value = credential.strip()
|
||||||
|
try:
|
||||||
|
decoded = onlyoffice_service.decode_onlyoffice_token(value)
|
||||||
|
except JWTError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名无效") from exc
|
||||||
|
signed = decoded.get("payload") if isinstance(decoded.get("payload"), dict) else decoded
|
||||||
|
if not isinstance(signed, dict):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调载荷无效")
|
||||||
|
signed_key = signed.get("key")
|
||||||
|
signed_status = signed.get("status")
|
||||||
|
if not isinstance(signed_key, str) or not hmac.compare_digest(signed_key, payload.key):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调 key 不匹配")
|
||||||
|
if not isinstance(signed_status, int) or signed_status != payload.status:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调状态不匹配")
|
||||||
|
if payload.url:
|
||||||
|
signed_url = signed.get("url")
|
||||||
|
if not isinstance(signed_url, str) or not hmac.compare_digest(signed_url, payload.url):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调文件地址不匹配")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
def _callback_fingerprint(payload: CollaborationCallbackPayload) -> str:
|
||||||
|
normalized = payload.model_dump(mode="json", exclude_none=True)
|
||||||
|
return hashlib.sha256(json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _url_origin_matches(actual, expected) -> bool:
|
||||||
|
actual_port = actual.port or (443 if actual.scheme == "https" else 80)
|
||||||
|
expected_port = expected.port or (443 if expected.scheme == "https" else 80)
|
||||||
|
return (
|
||||||
|
actual.scheme == expected.scheme
|
||||||
|
and actual.hostname
|
||||||
|
and actual.hostname.lower() == (expected.hostname or "").lower()
|
||||||
|
and actual_port == expected_port
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_result_url(url: str) -> str:
|
||||||
|
actual = urlsplit(url)
|
||||||
|
if (
|
||||||
|
actual.scheme not in {"http", "https"}
|
||||||
|
or actual.username
|
||||||
|
or actual.password
|
||||||
|
or actual.fragment
|
||||||
|
or not actual.hostname
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
|
||||||
|
|
||||||
|
internal = urlsplit(settings.ONLYOFFICE_INTERNAL_URL.rstrip("/"))
|
||||||
|
if _url_origin_matches(actual, internal):
|
||||||
|
return urlunsplit((internal.scheme, internal.netloc, actual.path, actual.query, ""))
|
||||||
|
|
||||||
|
public = urlsplit(settings.FRONTEND_PUBLIC_URL.rstrip("/"))
|
||||||
|
proxy_prefix = "/onlyoffice/"
|
||||||
|
if not _url_origin_matches(actual, public) or not actual.path.startswith(proxy_prefix):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
|
||||||
|
internal_path = f"{internal.path.rstrip('/')}/{actual.path[len(proxy_prefix):]}"
|
||||||
|
return urlunsplit((internal.scheme, internal.netloc, internal_path, actual.query, ""))
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_result(url: str) -> bytes:
|
||||||
|
download_url = _validate_result_url(url)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
|
||||||
|
async with client.stream("GET", download_url) as response:
|
||||||
|
if response.status_code != status.HTTP_200_OK:
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败")
|
||||||
|
content = bytearray()
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
content.extend(chunk)
|
||||||
|
if len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
|
||||||
|
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="ONLYOFFICE 保存文件超出限制")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败") from exc
|
||||||
|
return bytes(content)
|
||||||
|
|
||||||
|
|
||||||
|
async def _callback_user(
|
||||||
|
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
|
||||||
|
) -> User | None:
|
||||||
|
has_public_share_user = False
|
||||||
|
for value in payload.users:
|
||||||
|
if value.startswith("share-"):
|
||||||
|
has_public_share_user = True
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
user = await db.get(User, uuid.UUID(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
user = None
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
if has_public_share_user:
|
||||||
|
return None
|
||||||
|
user = await db.get(User, session.started_by)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话用户不存在")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def process_callback(
|
||||||
|
db: AsyncSession,
|
||||||
|
session_id: uuid.UUID,
|
||||||
|
payload: CollaborationCallbackPayload,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
session = await db.scalar(
|
||||||
|
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
|
||||||
|
)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
||||||
|
if not hmac.compare_digest(session.document_key, payload.key):
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话 key 不匹配")
|
||||||
|
fingerprint = _callback_fingerprint(payload)
|
||||||
|
duplicate = await db.scalar(select(CollaborationCallbackReceipt.id).where(
|
||||||
|
CollaborationCallbackReceipt.session_id == session.id,
|
||||||
|
CollaborationCallbackReceipt.fingerprint == fingerprint,
|
||||||
|
))
|
||||||
|
if duplicate:
|
||||||
|
return {"error": 0}
|
||||||
|
|
||||||
|
item = await db.get(CollaborationFile, session.file_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
|
||||||
|
session.last_callback_at = datetime.now(timezone.utc)
|
||||||
|
session.active_users = json.dumps(payload.users, ensure_ascii=True)
|
||||||
|
result = "ACKNOWLEDGED"
|
||||||
|
saved_revision_id = None
|
||||||
|
|
||||||
|
if payload.status in {2, 6}:
|
||||||
|
if not payload.url:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
|
||||||
|
if session.generation != item.generation:
|
||||||
|
result = "STALE"
|
||||||
|
else:
|
||||||
|
content = await _download_result(payload.url)
|
||||||
|
actor = await _callback_user(db, payload, session)
|
||||||
|
source = "SESSION_CLOSE" if payload.status == 2 else "FORCE_SAVE"
|
||||||
|
if actor is None:
|
||||||
|
source = "SHARE_SESSION_CLOSE" if payload.status == 2 else "SHARE_FORCE_SAVE"
|
||||||
|
revision, created = await collaboration_service.append_revision(
|
||||||
|
db, item, content, source=source, created_by=actor.id if actor else None
|
||||||
|
)
|
||||||
|
saved_revision_id = revision.id
|
||||||
|
result = "SAVED" if created else "UNCHANGED"
|
||||||
|
if payload.status == 2:
|
||||||
|
item.generation += 1
|
||||||
|
session.status = "CLOSED"
|
||||||
|
session.closed_at = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document
|
||||||
|
# Server 缓存重建时仍从最近一次持久化内容恢复。
|
||||||
|
session.base_revision_id = revision.id
|
||||||
|
elif payload.status == 4:
|
||||||
|
session.status = "CLOSED"
|
||||||
|
session.closed_at = datetime.now(timezone.utc)
|
||||||
|
result = "UNCHANGED"
|
||||||
|
elif payload.status in {3, 7}:
|
||||||
|
session.status = "ERROR"
|
||||||
|
result = "ERROR"
|
||||||
|
|
||||||
|
db.add(CollaborationCallbackReceipt(
|
||||||
|
session_id=session.id,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
callback_status=payload.status,
|
||||||
|
result=result,
|
||||||
|
revision_id=saved_revision_id,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
# ONLYOFFICE 要求回调处理器在接收并记录状态后固定确认成功。
|
||||||
|
# status 3/7 表示文档服务自身保存失败,不应通过 error=1 制造重试环。
|
||||||
|
return {"error": 0}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import status
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.exceptions import AppException
|
||||||
|
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||||
|
|
||||||
|
OnlyOfficeDocumentType = Literal["word", "cell", "slide"]
|
||||||
|
OnlyOfficeResourceType = Literal["attachment", "version", "collaboration_revision"]
|
||||||
|
|
||||||
|
WORD_FORMATS = frozenset({
|
||||||
|
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt",
|
||||||
|
})
|
||||||
|
CELL_FORMATS = frozenset({
|
||||||
|
"xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm", "ods", "ots", "csv", "et", "ett",
|
||||||
|
})
|
||||||
|
SLIDE_FORMATS = frozenset({
|
||||||
|
"ppt", "pptx", "pptm", "pps", "ppsx", "ppsm", "pot", "potx", "potm", "odp", "otp", "dps", "dpt",
|
||||||
|
})
|
||||||
|
|
||||||
|
_health_lock = asyncio.Lock()
|
||||||
|
_health_checked_at = 0.0
|
||||||
|
_health_available = False
|
||||||
|
_HEALTH_CACHE_SECONDS = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def onlyoffice_error(code: str, message: str, status_code: int) -> AppException:
|
||||||
|
return AppException(code=code, message=message, status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def office_format_for_filename(filename: str) -> tuple[str, OnlyOfficeDocumentType] | None:
|
||||||
|
suffix = Path(filename).suffix.lower().lstrip(".")
|
||||||
|
if suffix in WORD_FORMATS:
|
||||||
|
return suffix, "word"
|
||||||
|
if suffix in CELL_FORMATS:
|
||||||
|
return suffix, "cell"
|
||||||
|
if suffix in SLIDE_FORMATS:
|
||||||
|
return suffix, "slide"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def onlyoffice_content_url(resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID) -> str:
|
||||||
|
plural = {
|
||||||
|
"attachment": "attachments",
|
||||||
|
"version": "versions",
|
||||||
|
"collaboration_revision": "collaboration-revisions",
|
||||||
|
}[resource_type]
|
||||||
|
return (
|
||||||
|
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||||
|
f"/internal/onlyoffice/{plural}/{resource_id}/content"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def onlyoffice_document_key(
|
||||||
|
resource_type: OnlyOfficeResourceType,
|
||||||
|
resource_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
file_hash: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
instance_id = (settings.ONLYOFFICE_INSTANCE_ID or "").strip()
|
||||||
|
fingerprint = f"{instance_id}{resource_type}{resource_id}"
|
||||||
|
if resource_type == "version":
|
||||||
|
fingerprint = f"{fingerprint}{file_hash or ''}"
|
||||||
|
return f"ctms-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_onlyoffice_available() -> None:
|
||||||
|
global _health_available, _health_checked_at
|
||||||
|
if not settings.ONLYOFFICE_ENABLED:
|
||||||
|
raise onlyoffice_error(
|
||||||
|
"ONLYOFFICE_DISABLED",
|
||||||
|
"Office 预览服务尚未启用",
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _health_checked_at < _HEALTH_CACHE_SECONDS:
|
||||||
|
if _health_available:
|
||||||
|
return
|
||||||
|
raise onlyoffice_error(
|
||||||
|
"ONLYOFFICE_UNAVAILABLE",
|
||||||
|
"Office 预览服务暂不可用,请稍后重试",
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with _health_lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _health_checked_at >= _HEALTH_CACHE_SECONDS:
|
||||||
|
available = False
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=2.0, follow_redirects=False) as client:
|
||||||
|
response = await client.get(f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/healthcheck")
|
||||||
|
available = response.status_code == status.HTTP_200_OK
|
||||||
|
except httpx.HTTPError:
|
||||||
|
available = False
|
||||||
|
_health_available = available
|
||||||
|
_health_checked_at = now
|
||||||
|
|
||||||
|
if not _health_available:
|
||||||
|
raise onlyoffice_error(
|
||||||
|
"ONLYOFFICE_UNAVAILABLE",
|
||||||
|
"Office 预览服务暂不可用,请稍后重试",
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_preview_config(
|
||||||
|
*,
|
||||||
|
resource_type: OnlyOfficeResourceType,
|
||||||
|
resource_id: uuid.UUID,
|
||||||
|
file_name: str,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
user_name: str,
|
||||||
|
file_hash: str | None = None,
|
||||||
|
) -> OnlyOfficePreviewConfigRead:
|
||||||
|
format_info = office_format_for_filename(file_name)
|
||||||
|
if not format_info:
|
||||||
|
raise onlyoffice_error(
|
||||||
|
"ONLYOFFICE_FORMAT_UNSUPPORTED",
|
||||||
|
"该文件格式不支持 Office 在线预览",
|
||||||
|
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
)
|
||||||
|
file_type, document_type = format_info
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"type": "desktop",
|
||||||
|
"documentType": document_type,
|
||||||
|
"document": {
|
||||||
|
"fileType": file_type,
|
||||||
|
"key": onlyoffice_document_key(resource_type, resource_id, file_hash=file_hash),
|
||||||
|
"title": file_name,
|
||||||
|
"url": onlyoffice_content_url(resource_type, resource_id),
|
||||||
|
"permissions": {
|
||||||
|
"copy": False,
|
||||||
|
"comment": False,
|
||||||
|
"download": False,
|
||||||
|
"edit": False,
|
||||||
|
"fillForms": False,
|
||||||
|
"modifyContentControl": False,
|
||||||
|
"modifyFilter": False,
|
||||||
|
"print": False,
|
||||||
|
"review": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"editorConfig": {
|
||||||
|
"coEditing": {"mode": "strict", "change": False},
|
||||||
|
"customization": {
|
||||||
|
"chat": False,
|
||||||
|
"comments": False,
|
||||||
|
"forcesave": False,
|
||||||
|
},
|
||||||
|
"lang": "zh-CN",
|
||||||
|
"mode": "view",
|
||||||
|
"user": {"id": str(user_id), "name": user_name},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token_payload = {
|
||||||
|
**config,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int(expires_at.timestamp()),
|
||||||
|
}
|
||||||
|
config["token"] = jwt.encode(
|
||||||
|
token_payload,
|
||||||
|
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
return OnlyOfficePreviewConfigRead(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
file_name=file_name,
|
||||||
|
expires_at=expires_at,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any]:
|
||||||
|
if not token:
|
||||||
|
raise onlyoffice_error(
|
||||||
|
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
|
||||||
|
"无法验证 Office 文件请求",
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
token = token.strip()
|
||||||
|
if " " in token:
|
||||||
|
scheme, credential = token.split(" ", 1)
|
||||||
|
if scheme.lower() != "bearer" or not credential.strip():
|
||||||
|
raise onlyoffice_error(
|
||||||
|
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
|
||||||
|
"无法验证 Office 文件请求",
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
token = credential.strip()
|
||||||
|
try:
|
||||||
|
payload = decode_onlyoffice_token(token)
|
||||||
|
except JWTError as exc:
|
||||||
|
raise onlyoffice_error(
|
||||||
|
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
|
||||||
|
"无法验证 Office 文件请求",
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
request_payload = payload.get("payload")
|
||||||
|
token_url = request_payload.get("url") if isinstance(request_payload, dict) else None
|
||||||
|
if not isinstance(token_url, str) or not hmac.compare_digest(token_url, expected_url):
|
||||||
|
raise onlyoffice_error(
|
||||||
|
"ONLYOFFICE_SOURCE_URL_MISMATCH",
|
||||||
|
"Office 文件请求地址不匹配",
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def decode_onlyoffice_token(token: str) -> dict[str, Any]:
|
||||||
|
header = jwt.get_unverified_header(token)
|
||||||
|
if header.get("alg") != "HS256":
|
||||||
|
raise JWTError("unexpected algorithm")
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
@@ -13,6 +13,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.models.permission_access_log import PermissionAccessLog
|
from app.models.permission_access_log import PermissionAccessLog
|
||||||
@@ -22,57 +23,85 @@ logger = logging.getLogger("ctms.permission_log_writer")
|
|||||||
BATCH_SIZE = 50
|
BATCH_SIZE = 50
|
||||||
FLUSH_INTERVAL = 5.0
|
FLUSH_INTERVAL = 5.0
|
||||||
QUEUE_MAX_SIZE = 10000
|
QUEUE_MAX_SIZE = 10000
|
||||||
|
WRITE_ATTEMPTS = 2
|
||||||
|
_QUEUE_STOP = object()
|
||||||
|
|
||||||
|
|
||||||
class PermissionLogWriter:
|
class PermissionLogWriter:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
self._queue: asyncio.Queue[dict | object] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
|
self._stopping = False
|
||||||
|
self._started_at: datetime | None = None
|
||||||
|
self._last_success_at: datetime | None = None
|
||||||
|
self._last_error_at: datetime | None = None
|
||||||
|
self._last_error_type: str | None = None
|
||||||
|
self._accepted_entries = 0
|
||||||
|
self._written_entries = 0
|
||||||
|
self._dropped_entries = 0
|
||||||
|
self._failed_batches = 0
|
||||||
|
self._failed_entries = 0
|
||||||
|
self._retry_count = 0
|
||||||
|
|
||||||
def enqueue(self, entry: dict) -> None:
|
def enqueue(self, entry: dict) -> None:
|
||||||
|
if self._stopping:
|
||||||
|
self._dropped_entries += 1
|
||||||
|
logger.warning("Permission log writer is stopping, dropping entry")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self._queue.put_nowait(entry)
|
self._queue.put_nowait(entry)
|
||||||
|
self._accepted_entries += 1
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
|
self._dropped_entries += 1
|
||||||
logger.warning("Permission log queue full, dropping entry")
|
logger.warning("Permission log queue full, dropping entry")
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
|
if self._task and not self._task.done():
|
||||||
|
return
|
||||||
|
self._stopping = False
|
||||||
|
self._started_at = datetime.now(timezone.utc)
|
||||||
self._task = asyncio.create_task(self._flush_loop())
|
self._task = asyncio.create_task(self._flush_loop())
|
||||||
logger.info("PermissionLogWriter started")
|
logger.info("PermissionLogWriter started")
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
if self._task:
|
if self._task and not self._task.done():
|
||||||
self._task.cancel()
|
self._stopping = True
|
||||||
try:
|
await self._queue.put(_QUEUE_STOP)
|
||||||
await self._task
|
await self._task
|
||||||
except asyncio.CancelledError:
|
self._task = None
|
||||||
pass
|
|
||||||
await self._drain()
|
|
||||||
logger.info("PermissionLogWriter stopped")
|
logger.info("PermissionLogWriter stopped")
|
||||||
|
|
||||||
async def _flush_loop(self) -> None:
|
async def _flush_loop(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
batch = await self._collect_batch()
|
batch, should_stop = await self._collect_batch()
|
||||||
if batch:
|
if batch:
|
||||||
await self._write_batch(batch)
|
await self._write_batch(batch)
|
||||||
|
if should_stop:
|
||||||
|
break
|
||||||
|
|
||||||
async def _collect_batch(self) -> list[dict]:
|
async def _collect_batch(self) -> tuple[list[dict], bool]:
|
||||||
batch: list[dict] = []
|
batch: list[dict] = []
|
||||||
try:
|
try:
|
||||||
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
||||||
|
if first is _QUEUE_STOP:
|
||||||
|
return batch, True
|
||||||
batch.append(first)
|
batch.append(first)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return batch
|
return batch, False
|
||||||
|
|
||||||
while len(batch) < BATCH_SIZE:
|
while len(batch) < BATCH_SIZE:
|
||||||
try:
|
try:
|
||||||
item = self._queue.get_nowait()
|
item = self._queue.get_nowait()
|
||||||
|
if item is _QUEUE_STOP:
|
||||||
|
return batch, True
|
||||||
batch.append(item)
|
batch.append(item)
|
||||||
except asyncio.QueueEmpty:
|
except asyncio.QueueEmpty:
|
||||||
break
|
break
|
||||||
return batch
|
return batch, False
|
||||||
|
|
||||||
async def _write_batch(self, batch: list[dict]) -> None:
|
async def _write_batch(self, batch: list[dict]) -> bool:
|
||||||
|
for attempt in range(WRITE_ATTEMPTS):
|
||||||
try:
|
try:
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
for entry in batch:
|
for entry in batch:
|
||||||
@@ -85,22 +114,59 @@ class PermissionLogWriter:
|
|||||||
allowed=entry["allowed"],
|
allowed=entry["allowed"],
|
||||||
elapsed_ms=entry["elapsed_ms"],
|
elapsed_ms=entry["elapsed_ms"],
|
||||||
ip_address=entry.get("ip_address"),
|
ip_address=entry.get("ip_address"),
|
||||||
|
user_agent=entry.get("user_agent"),
|
||||||
|
client_type=entry.get("client_type"),
|
||||||
|
client_version=entry.get("client_version"),
|
||||||
|
client_platform=entry.get("client_platform"),
|
||||||
|
build_channel=entry.get("build_channel"),
|
||||||
|
build_commit=entry.get("build_commit"),
|
||||||
|
request_headers=entry.get("request_headers"),
|
||||||
|
request_snapshot=entry.get("request_snapshot"),
|
||||||
|
request_id=entry.get("request_id"),
|
||||||
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
||||||
)
|
)
|
||||||
session.add(log)
|
session.add(log)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Failed to write permission access log batch (%d entries)", len(batch))
|
self._last_error_at = datetime.now(timezone.utc)
|
||||||
|
self._last_error_type = type(exc).__name__
|
||||||
|
if attempt + 1 < WRITE_ATTEMPTS:
|
||||||
|
self._retry_count += 1
|
||||||
|
logger.warning(
|
||||||
|
"Permission access log batch write failed; retrying (%d entries)",
|
||||||
|
len(batch),
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
continue
|
||||||
|
self._failed_batches += 1
|
||||||
|
self._failed_entries += len(batch)
|
||||||
|
logger.exception(
|
||||||
|
"Failed to write permission access log batch after retries (%d entries)",
|
||||||
|
len(batch),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
self._written_entries += len(batch)
|
||||||
|
self._last_success_at = datetime.now(timezone.utc)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def _drain(self) -> None:
|
def stats(self) -> dict[str, Any]:
|
||||||
batch: list[dict] = []
|
return {
|
||||||
while not self._queue.empty():
|
"running": bool(self._task and not self._task.done()),
|
||||||
try:
|
"stopping": self._stopping,
|
||||||
batch.append(self._queue.get_nowait())
|
"queue_size": self._queue.qsize(),
|
||||||
except asyncio.QueueEmpty:
|
"queue_capacity": self._queue.maxsize,
|
||||||
break
|
"accepted_entries": self._accepted_entries,
|
||||||
if batch:
|
"written_entries": self._written_entries,
|
||||||
await self._write_batch(batch)
|
"dropped_entries": self._dropped_entries,
|
||||||
|
"failed_batches": self._failed_batches,
|
||||||
|
"failed_entries": self._failed_entries,
|
||||||
|
"retry_count": self._retry_count,
|
||||||
|
"started_at": self._started_at.isoformat() if self._started_at else None,
|
||||||
|
"last_success_at": self._last_success_at.isoformat() if self._last_success_at else None,
|
||||||
|
"last_error_at": self._last_error_at.isoformat() if self._last_error_at else None,
|
||||||
|
"last_error_type": self._last_error_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_writer: PermissionLogWriter | None = None
|
_writer: PermissionLogWriter | None = None
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
|
|||||||
|
|
||||||
from app.core.permission_monitor import get_permission_monitor
|
from app.core.permission_monitor import get_permission_monitor
|
||||||
monitor = get_permission_monitor()
|
monitor = get_permission_monitor()
|
||||||
cache_metrics = monitor.metrics.cache_metrics
|
cache_metrics = monitor.metrics.cache_metrics.window_stats(3600)
|
||||||
|
|
||||||
snapshot = PermissionMetricSnapshot(
|
snapshot = PermissionMetricSnapshot(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -50,8 +50,8 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
|
|||||||
denied_checks=row.denied,
|
denied_checks=row.denied,
|
||||||
avg_elapsed_ms=float(row.avg_ms),
|
avg_elapsed_ms=float(row.avg_ms),
|
||||||
max_elapsed_ms=float(row.max_ms),
|
max_elapsed_ms=float(row.max_ms),
|
||||||
cache_hits=cache_metrics.cache_hits,
|
cache_hits=cache_metrics["cache_hits"],
|
||||||
cache_misses=cache_metrics.cache_misses,
|
cache_misses=cache_metrics["cache_misses"],
|
||||||
error_count=0,
|
error_count=0,
|
||||||
)
|
)
|
||||||
session.add(snapshot)
|
session.add(snapshot)
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_cra_site_scope, is_system_admin
|
||||||
|
from app.core.project_permissions import role_has_api_permission
|
||||||
|
from app.crud import member as member_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
|
from app.models.acknowledgement import Acknowledgement, AcknowledgementType
|
||||||
|
from app.models.ae import AdverseEvent
|
||||||
|
from app.models.collaboration import CollaborationEditRequest, CollaborationFile, CollaborationMember
|
||||||
|
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||||
|
from app.models.document import Document, DocumentStatus
|
||||||
|
from app.models.document_version import DocumentVersion
|
||||||
|
from app.models.milestone import Milestone
|
||||||
|
from app.models.monitoring_visit_issue import MonitoringVisitIssue
|
||||||
|
from app.models.site import Site
|
||||||
|
from app.models.subject import Subject
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.visit import Visit
|
||||||
|
from app.services import notification_service
|
||||||
|
|
||||||
|
BUSINESS_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||||
|
RISK_DUE_SOON_DAYS = 3
|
||||||
|
MILESTONE_DUE_SOON_DAYS = 7
|
||||||
|
VISIT_WINDOW_DUE_SOON_DAYS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _date_due_at(value: date | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return datetime.combine(value, time.max, tzinfo=BUSINESS_TIMEZONE).astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def _count_and_earliest(db: AsyncSession, model, *filters):
|
||||||
|
return (await db.execute(
|
||||||
|
select(func.count(model.id), func.min(model.report_due_date if model is AdverseEvent else model.due_at))
|
||||||
|
.where(*filters)
|
||||||
|
)).one()
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_risk_reminders(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
user,
|
||||||
|
role: str,
|
||||||
|
current_time: datetime,
|
||||||
|
) -> None:
|
||||||
|
can_read_aes = is_system_admin(user) or await role_has_api_permission(
|
||||||
|
db, study_id, role, "subject_aes:read"
|
||||||
|
)
|
||||||
|
can_read_monitoring = is_system_admin(user) or await role_has_api_permission(
|
||||||
|
db, study_id, role, "monitoring_issues:read"
|
||||||
|
)
|
||||||
|
cra_scope = await get_cra_site_scope(db, study_id, user)
|
||||||
|
site_ids = cra_scope[0] if cra_scope else None
|
||||||
|
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
|
||||||
|
due_soon_date = today + timedelta(days=RISK_DUE_SOON_DAYS)
|
||||||
|
|
||||||
|
ae_scope = [AdverseEvent.study_id == study_id, AdverseEvent.status != "CLOSED"]
|
||||||
|
if site_ids is not None:
|
||||||
|
ae_scope.append(AdverseEvent.site_id.in_(site_ids))
|
||||||
|
overdue_aes = 0
|
||||||
|
overdue_ae_due = None
|
||||||
|
due_soon_aes = 0
|
||||||
|
due_soon_ae_due = None
|
||||||
|
if can_read_aes:
|
||||||
|
overdue_aes, overdue_ae_due = await _count_and_earliest(
|
||||||
|
db,
|
||||||
|
AdverseEvent,
|
||||||
|
*ae_scope,
|
||||||
|
AdverseEvent.report_due_date.is_not(None),
|
||||||
|
AdverseEvent.report_due_date < today,
|
||||||
|
)
|
||||||
|
due_soon_aes, due_soon_ae_due = await _count_and_earliest(
|
||||||
|
db,
|
||||||
|
AdverseEvent,
|
||||||
|
*ae_scope,
|
||||||
|
AdverseEvent.report_due_date >= today,
|
||||||
|
AdverseEvent.report_due_date <= due_soon_date,
|
||||||
|
)
|
||||||
|
await notification_service.sync_aggregate_notification(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
category="RISK_OVERDUE_AE",
|
||||||
|
priority="URGENT",
|
||||||
|
title="逾期 AE 待处理",
|
||||||
|
message=f"当前有 {overdue_aes} 条逾期 AE 需要跟进",
|
||||||
|
action_path="/risk-issues/sae",
|
||||||
|
source_type="RISK_OVERDUE_AE",
|
||||||
|
source_id=str(study_id),
|
||||||
|
count=int(overdue_aes) if can_read_aes else 0,
|
||||||
|
due_at=_date_due_at(overdue_ae_due),
|
||||||
|
)
|
||||||
|
await notification_service.sync_aggregate_notification(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
category="RISK_AE_DUE_SOON",
|
||||||
|
priority="HIGH",
|
||||||
|
title="AE 上报时限临近",
|
||||||
|
message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_aes} 条 AE 到达上报时限",
|
||||||
|
action_path="/risk-issues/sae",
|
||||||
|
source_type="RISK_AE_DUE_SOON",
|
||||||
|
source_id=str(study_id),
|
||||||
|
count=int(due_soon_aes) if can_read_aes else 0,
|
||||||
|
due_at=_date_due_at(due_soon_ae_due),
|
||||||
|
)
|
||||||
|
|
||||||
|
monitoring_scope = [
|
||||||
|
MonitoringVisitIssue.study_id == study_id,
|
||||||
|
MonitoringVisitIssue.status == "OPEN",
|
||||||
|
MonitoringVisitIssue.due_at.is_not(None),
|
||||||
|
]
|
||||||
|
if site_ids is not None:
|
||||||
|
monitoring_scope.append(MonitoringVisitIssue.site_id.in_(site_ids))
|
||||||
|
overdue_monitoring = 0
|
||||||
|
overdue_monitoring_due = None
|
||||||
|
due_soon_monitoring = 0
|
||||||
|
due_soon_monitoring_due = None
|
||||||
|
if can_read_monitoring:
|
||||||
|
overdue_monitoring, overdue_monitoring_due = await _count_and_earliest(
|
||||||
|
db,
|
||||||
|
MonitoringVisitIssue,
|
||||||
|
*monitoring_scope,
|
||||||
|
MonitoringVisitIssue.due_at < current_time,
|
||||||
|
)
|
||||||
|
due_soon_monitoring, due_soon_monitoring_due = await _count_and_earliest(
|
||||||
|
db,
|
||||||
|
MonitoringVisitIssue,
|
||||||
|
*monitoring_scope,
|
||||||
|
MonitoringVisitIssue.due_at >= current_time,
|
||||||
|
MonitoringVisitIssue.due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS),
|
||||||
|
)
|
||||||
|
await notification_service.sync_aggregate_notification(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
category="RISK_OVERDUE_MONITORING",
|
||||||
|
priority="HIGH",
|
||||||
|
title="监查问题已逾期",
|
||||||
|
message=f"当前有 {overdue_monitoring} 条监查问题已超过计划解决日期",
|
||||||
|
action_path="/risk-issues/monitoring-visits",
|
||||||
|
source_type="RISK_OVERDUE_MONITORING",
|
||||||
|
source_id=str(study_id),
|
||||||
|
count=int(overdue_monitoring) if can_read_monitoring else 0,
|
||||||
|
due_at=_as_utc(overdue_monitoring_due),
|
||||||
|
)
|
||||||
|
await notification_service.sync_aggregate_notification(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
category="RISK_MONITORING_DUE_SOON",
|
||||||
|
priority="HIGH",
|
||||||
|
title="监查问题整改时限临近",
|
||||||
|
message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_monitoring} 条监查问题到期",
|
||||||
|
action_path="/risk-issues/monitoring-visits",
|
||||||
|
source_type="RISK_MONITORING_DUE_SOON",
|
||||||
|
source_id=str(study_id),
|
||||||
|
count=int(due_soon_monitoring) if can_read_monitoring else 0,
|
||||||
|
due_at=_as_utc(due_soon_monitoring_due),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_document_distribution_reminders(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
user,
|
||||||
|
role: str,
|
||||||
|
current_time: datetime,
|
||||||
|
) -> None:
|
||||||
|
can_read_documents = is_system_admin(user) or await role_has_api_permission(
|
||||||
|
db, study_id, role, "documents:read"
|
||||||
|
)
|
||||||
|
if not can_read_documents:
|
||||||
|
await notification_service.resolve_missing_recipient_sources(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
source_type="DOCUMENT_DISTRIBUTION",
|
||||||
|
active_source_ids=set(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
site_ids = await site_crud.list_ids_by_contact_user(db, study_id, user.id)
|
||||||
|
target_filters = [
|
||||||
|
and_(
|
||||||
|
Distribution.target_type == DistributionTargetType.USER,
|
||||||
|
Distribution.target_id == str(user.id),
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
Distribution.target_type == DistributionTargetType.ROLE,
|
||||||
|
Distribution.target_id == role,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if site_ids:
|
||||||
|
target_filters.append(and_(
|
||||||
|
Distribution.target_type == DistributionTargetType.SITE,
|
||||||
|
Distribution.target_id.in_({str(site_id) for site_id in site_ids}),
|
||||||
|
))
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Distribution, Document, DocumentVersion)
|
||||||
|
.join(Document, Distribution.document_id == Document.id)
|
||||||
|
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||||
|
.outerjoin(
|
||||||
|
Acknowledgement,
|
||||||
|
and_(
|
||||||
|
Acknowledgement.distribution_id == Distribution.id,
|
||||||
|
Acknowledgement.user_id == user.id,
|
||||||
|
Acknowledgement.ack_type == AcknowledgementType.RECEIVED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Document.trial_id == study_id,
|
||||||
|
Document.status == DocumentStatus.ACTIVE,
|
||||||
|
Distribution.status == DistributionStatus.ACTIVE,
|
||||||
|
Acknowledgement.id.is_(None),
|
||||||
|
or_(*target_filters),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
active_source_ids: set[str] = set()
|
||||||
|
for distribution, document, version in rows:
|
||||||
|
source_id = str(distribution.id)
|
||||||
|
active_source_ids.add(source_id)
|
||||||
|
distribution_due_at = _as_utc(distribution.due_at)
|
||||||
|
if distribution_due_at and distribution_due_at < current_time:
|
||||||
|
stage = "OVERDUE"
|
||||||
|
category = "DOCUMENT_ACK_OVERDUE"
|
||||||
|
priority = "URGENT"
|
||||||
|
title = "文件回执已逾期"
|
||||||
|
elif distribution_due_at and distribution_due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS):
|
||||||
|
stage = "DUE_SOON"
|
||||||
|
category = "DOCUMENT_ACK_DUE_SOON"
|
||||||
|
priority = "HIGH"
|
||||||
|
title = "文件回执即将到期"
|
||||||
|
else:
|
||||||
|
stage = "PENDING"
|
||||||
|
category = "DOCUMENT_DISTRIBUTION"
|
||||||
|
priority = "NORMAL"
|
||||||
|
title = "有新的文件版本待接收"
|
||||||
|
due_version = distribution_due_at.isoformat() if distribution_due_at else "none"
|
||||||
|
await notification_service.sync_state_notification(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
category=category,
|
||||||
|
priority=priority,
|
||||||
|
title=title,
|
||||||
|
message=f"“{document.title}” {version.version_no} 已分发,请完成接收回执",
|
||||||
|
action_path=f"/documents/{document.id}",
|
||||||
|
source_type="DOCUMENT_DISTRIBUTION",
|
||||||
|
source_id=source_id,
|
||||||
|
source_version=f"{stage}:{due_version}",
|
||||||
|
active=True,
|
||||||
|
due_at=distribution_due_at,
|
||||||
|
)
|
||||||
|
await notification_service.resolve_missing_recipient_sources(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
source_type="DOCUMENT_DISTRIBUTION",
|
||||||
|
active_source_ids=active_source_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_milestone_reminders(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
user,
|
||||||
|
role: str,
|
||||||
|
current_time: datetime,
|
||||||
|
) -> None:
|
||||||
|
can_read = is_system_admin(user) or await role_has_api_permission(
|
||||||
|
db, study_id, role, "project_milestones:read"
|
||||||
|
)
|
||||||
|
active_source_ids: set[str] = set()
|
||||||
|
if can_read:
|
||||||
|
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
|
||||||
|
due_soon_date = today + timedelta(days=MILESTONE_DUE_SOON_DAYS)
|
||||||
|
milestones = (await db.scalars(
|
||||||
|
select(Milestone).where(
|
||||||
|
Milestone.study_id == study_id,
|
||||||
|
Milestone.owner_id == user.id,
|
||||||
|
Milestone.status != "DONE",
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
for milestone in milestones:
|
||||||
|
due_date = milestone.adjusted_end_date or milestone.planned_date
|
||||||
|
if due_date is None or due_date > due_soon_date:
|
||||||
|
continue
|
||||||
|
source_id = str(milestone.id)
|
||||||
|
active_source_ids.add(source_id)
|
||||||
|
overdue = due_date < today
|
||||||
|
await notification_service.sync_state_notification(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
category="MILESTONE_OVERDUE" if overdue else "MILESTONE_DUE_SOON",
|
||||||
|
priority="HIGH" if overdue else "NORMAL",
|
||||||
|
title="项目里程碑已逾期" if overdue else "项目里程碑即将到期",
|
||||||
|
message=f"“{milestone.name}”计划日期为 {due_date.isoformat()}",
|
||||||
|
action_path="/project/milestones",
|
||||||
|
source_type="PROJECT_MILESTONE",
|
||||||
|
source_id=source_id,
|
||||||
|
source_version=f"{'OVERDUE' if overdue else 'DUE_SOON'}:{due_date.isoformat()}:{milestone.status}",
|
||||||
|
active=True,
|
||||||
|
due_at=_date_due_at(due_date),
|
||||||
|
)
|
||||||
|
await notification_service.resolve_missing_recipient_sources(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
source_type="PROJECT_MILESTONE",
|
||||||
|
active_source_ids=active_source_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_visit_window_reminders(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
user,
|
||||||
|
role: str,
|
||||||
|
current_time: datetime,
|
||||||
|
) -> None:
|
||||||
|
"""Project active visit windows to the members who can actually maintain them."""
|
||||||
|
can_manage_visits = is_system_admin(user) or await role_has_api_permission(
|
||||||
|
db, study_id, role, "visits:update"
|
||||||
|
)
|
||||||
|
active_source_ids: set[str] = set()
|
||||||
|
if can_manage_visits:
|
||||||
|
cra_scope = await get_cra_site_scope(db, study_id, user)
|
||||||
|
site_ids = cra_scope[0] if cra_scope else None
|
||||||
|
filters = [
|
||||||
|
Visit.study_id == study_id,
|
||||||
|
Visit.actual_date.is_(None),
|
||||||
|
Visit.status.not_in(["DONE", "CANCELLED"]),
|
||||||
|
Subject.status.not_in(["COMPLETED", "DROPPED"]),
|
||||||
|
Site.is_active.is_(True),
|
||||||
|
]
|
||||||
|
if site_ids is not None:
|
||||||
|
filters.append(Subject.site_id.in_(site_ids))
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Visit, Subject)
|
||||||
|
.join(Subject, Subject.id == Visit.subject_id)
|
||||||
|
.join(Site, Site.id == Subject.site_id)
|
||||||
|
.where(*filters)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
|
||||||
|
due_soon_date = today + timedelta(days=VISIT_WINDOW_DUE_SOON_DAYS)
|
||||||
|
for visit, subject in rows:
|
||||||
|
window_start = visit.window_start or visit.planned_date
|
||||||
|
window_end = visit.window_end or visit.planned_date
|
||||||
|
if window_start is None or window_end is None or window_start > window_end:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if window_end < today:
|
||||||
|
category = "VISIT_WINDOW_MISSED"
|
||||||
|
priority = "HIGH"
|
||||||
|
title = "受试者访视已错过窗口"
|
||||||
|
message = "一个受试者访视已错过计划窗口,请进入详情核对并处理"
|
||||||
|
stage = "MISSED"
|
||||||
|
elif window_start <= due_soon_date:
|
||||||
|
category = "VISIT_WINDOW_DUE_SOON"
|
||||||
|
priority = "NORMAL"
|
||||||
|
title = "受试者访视窗口临近"
|
||||||
|
message = "一个受试者访视已进入近期窗口,请及时核对访视安排"
|
||||||
|
stage = "DUE_SOON"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_id = str(visit.id)
|
||||||
|
active_source_ids.add(source_id)
|
||||||
|
await notification_service.sync_state_notification(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
category=category,
|
||||||
|
priority=priority,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
action_path=f"/subjects/{subject.id}",
|
||||||
|
source_type="SUBJECT_VISIT_WINDOW",
|
||||||
|
source_id=source_id,
|
||||||
|
source_version=f"{stage}:{window_start.isoformat()}:{window_end.isoformat()}",
|
||||||
|
active=True,
|
||||||
|
due_at=_date_due_at(window_end),
|
||||||
|
)
|
||||||
|
await notification_service.resolve_missing_recipient_sources(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
source_type="SUBJECT_VISIT_WINDOW",
|
||||||
|
active_source_ids=active_source_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_collaboration_edit_request_reminders(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
user,
|
||||||
|
role: str,
|
||||||
|
) -> None:
|
||||||
|
can_read = is_system_admin(user) or await role_has_api_permission(
|
||||||
|
db, study_id, role, "collaboration:read"
|
||||||
|
)
|
||||||
|
if not can_read:
|
||||||
|
await notification_service.resolve_missing_recipient_sources(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
source_type="COLLABORATION_EDIT_REQUEST",
|
||||||
|
active_source_ids=set(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
manages_file = or_(
|
||||||
|
CollaborationFile.owner_id == user.id,
|
||||||
|
select(CollaborationMember.id).where(
|
||||||
|
CollaborationMember.file_id == CollaborationFile.id,
|
||||||
|
CollaborationMember.user_id == user.id,
|
||||||
|
CollaborationMember.role == "MANAGER",
|
||||||
|
).exists(),
|
||||||
|
)
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(CollaborationEditRequest, CollaborationFile, User)
|
||||||
|
.join(CollaborationFile, CollaborationFile.id == CollaborationEditRequest.file_id)
|
||||||
|
.join(User, User.id == CollaborationEditRequest.requester_id)
|
||||||
|
.where(
|
||||||
|
CollaborationFile.study_id == study_id,
|
||||||
|
CollaborationFile.status == "ACTIVE",
|
||||||
|
CollaborationFile.deleted_at.is_(None),
|
||||||
|
CollaborationEditRequest.status == "PENDING",
|
||||||
|
manages_file,
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
active_source_ids: set[str] = set()
|
||||||
|
for request, item, requester in rows:
|
||||||
|
source_id = str(request.id)
|
||||||
|
active_source_ids.add(source_id)
|
||||||
|
requester_name = str(requester.full_name or requester.email or "项目成员")
|
||||||
|
await notification_service.sync_state_notification(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
category="COLLABORATION_EDIT_REQUEST",
|
||||||
|
priority="NORMAL",
|
||||||
|
title="新的编辑权限申请",
|
||||||
|
message=f"{requester_name} 申请编辑“{item.title}”",
|
||||||
|
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
|
||||||
|
source_type="COLLABORATION_EDIT_REQUEST",
|
||||||
|
source_id=source_id,
|
||||||
|
source_version="PENDING",
|
||||||
|
active=True,
|
||||||
|
dedupe_key=f"collaboration-edit-request:{request.id}",
|
||||||
|
)
|
||||||
|
await notification_service.resolve_missing_recipient_sources(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
source_type="COLLABORATION_EDIT_REQUEST",
|
||||||
|
active_source_ids=active_source_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_project_reminders(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
user,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
membership = await member_crud.get_member(db, study_id, user.id)
|
||||||
|
if not membership or not membership.is_active:
|
||||||
|
await notification_service.resolve_recipient_study_notifications(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
recipient_id=user.id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return
|
||||||
|
role = membership.role_in_study
|
||||||
|
current_time = now or datetime.now(timezone.utc)
|
||||||
|
if current_time.tzinfo is None:
|
||||||
|
current_time = current_time.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
current_time = current_time.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
await _sync_risk_reminders(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
current_time=current_time,
|
||||||
|
)
|
||||||
|
await _sync_document_distribution_reminders(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
current_time=current_time,
|
||||||
|
)
|
||||||
|
await _sync_milestone_reminders(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
current_time=current_time,
|
||||||
|
)
|
||||||
|
await _sync_visit_window_reminders(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
current_time=current_time,
|
||||||
|
)
|
||||||
|
await _sync_collaboration_edit_request_reminders(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
@@ -6,67 +6,105 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.models.security_access_log import SecurityAccessLog
|
from app.models.security_access_log import SecurityAccessLog
|
||||||
|
from app.services.ip_location import resolve_ip_location
|
||||||
|
from app.services.security_events import classify_security_event
|
||||||
|
|
||||||
logger = logging.getLogger("ctms.security_access_log_writer")
|
logger = logging.getLogger("ctms.security_access_log_writer")
|
||||||
|
|
||||||
BATCH_SIZE = 100
|
BATCH_SIZE = 100
|
||||||
FLUSH_INTERVAL = 3.0
|
FLUSH_INTERVAL = 3.0
|
||||||
QUEUE_MAX_SIZE = 20000
|
QUEUE_MAX_SIZE = 20000
|
||||||
|
WRITE_ATTEMPTS = 2
|
||||||
|
_QUEUE_STOP = object()
|
||||||
|
|
||||||
|
|
||||||
class SecurityAccessLogWriter:
|
class SecurityAccessLogWriter:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
self._queue: asyncio.Queue[dict | object] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
|
self._stopping = False
|
||||||
|
self._started_at: datetime | None = None
|
||||||
|
self._last_success_at: datetime | None = None
|
||||||
|
self._last_error_at: datetime | None = None
|
||||||
|
self._last_error_type: str | None = None
|
||||||
|
self._accepted_entries = 0
|
||||||
|
self._written_entries = 0
|
||||||
|
self._dropped_entries = 0
|
||||||
|
self._failed_batches = 0
|
||||||
|
self._failed_entries = 0
|
||||||
|
self._retry_count = 0
|
||||||
|
|
||||||
def enqueue(self, entry: dict) -> None:
|
def enqueue(self, entry: dict) -> None:
|
||||||
|
if self._stopping:
|
||||||
|
self._dropped_entries += 1
|
||||||
|
logger.warning("Security access log writer is stopping, dropping entry")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self._queue.put_nowait(entry)
|
self._queue.put_nowait(entry)
|
||||||
|
self._accepted_entries += 1
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
|
self._dropped_entries += 1
|
||||||
logger.warning("Security access log queue full, dropping entry")
|
logger.warning("Security access log queue full, dropping entry")
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
|
if self._task and not self._task.done():
|
||||||
|
return
|
||||||
|
self._stopping = False
|
||||||
|
self._started_at = datetime.now(timezone.utc)
|
||||||
self._task = asyncio.create_task(self._flush_loop())
|
self._task = asyncio.create_task(self._flush_loop())
|
||||||
logger.info("SecurityAccessLogWriter started")
|
logger.info("SecurityAccessLogWriter started")
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
if self._task:
|
if self._task and not self._task.done():
|
||||||
self._task.cancel()
|
self._stopping = True
|
||||||
try:
|
await self._queue.put(_QUEUE_STOP)
|
||||||
await self._task
|
await self._task
|
||||||
except asyncio.CancelledError:
|
self._task = None
|
||||||
pass
|
|
||||||
await self._drain()
|
|
||||||
logger.info("SecurityAccessLogWriter stopped")
|
logger.info("SecurityAccessLogWriter stopped")
|
||||||
|
|
||||||
async def _flush_loop(self) -> None:
|
async def _flush_loop(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
batch = await self._collect_batch()
|
batch, should_stop = await self._collect_batch()
|
||||||
if batch:
|
if batch:
|
||||||
await self._write_batch(batch)
|
await self._write_batch(batch)
|
||||||
|
if should_stop:
|
||||||
|
break
|
||||||
|
|
||||||
async def _collect_batch(self) -> list[dict]:
|
async def _collect_batch(self) -> tuple[list[dict], bool]:
|
||||||
batch: list[dict] = []
|
batch: list[dict] = []
|
||||||
try:
|
try:
|
||||||
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
|
||||||
|
if first is _QUEUE_STOP:
|
||||||
|
return batch, True
|
||||||
batch.append(first)
|
batch.append(first)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return batch
|
return batch, False
|
||||||
|
|
||||||
while len(batch) < BATCH_SIZE:
|
while len(batch) < BATCH_SIZE:
|
||||||
try:
|
try:
|
||||||
batch.append(self._queue.get_nowait())
|
item = self._queue.get_nowait()
|
||||||
|
if item is _QUEUE_STOP:
|
||||||
|
return batch, True
|
||||||
|
batch.append(item)
|
||||||
except asyncio.QueueEmpty:
|
except asyncio.QueueEmpty:
|
||||||
break
|
break
|
||||||
return batch
|
return batch, False
|
||||||
|
|
||||||
async def _write_batch(self, batch: list[dict]) -> None:
|
async def _write_batch(self, batch: list[dict]) -> bool:
|
||||||
|
for attempt in range(WRITE_ATTEMPTS):
|
||||||
try:
|
try:
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
for entry in batch:
|
for entry in batch:
|
||||||
|
classification = classify_security_event(
|
||||||
|
path=entry["path"],
|
||||||
|
status_code=entry["status_code"],
|
||||||
|
auth_status=entry["auth_status"],
|
||||||
|
ip_location=resolve_ip_location(entry.get("client_ip")),
|
||||||
|
)
|
||||||
session.add(
|
session.add(
|
||||||
SecurityAccessLog(
|
SecurityAccessLog(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -76,24 +114,62 @@ class SecurityAccessLogWriter:
|
|||||||
elapsed_ms=entry["elapsed_ms"],
|
elapsed_ms=entry["elapsed_ms"],
|
||||||
client_ip=entry.get("client_ip"),
|
client_ip=entry.get("client_ip"),
|
||||||
user_agent=entry.get("user_agent"),
|
user_agent=entry.get("user_agent"),
|
||||||
|
client_type=entry.get("client_type"),
|
||||||
|
client_version=entry.get("client_version"),
|
||||||
|
client_platform=entry.get("client_platform"),
|
||||||
|
build_channel=entry.get("build_channel"),
|
||||||
|
build_commit=entry.get("build_commit"),
|
||||||
|
request_headers=entry.get("request_headers"),
|
||||||
|
request_snapshot=entry.get("request_snapshot"),
|
||||||
|
request_id=entry.get("request_id"),
|
||||||
|
category=classification["category"],
|
||||||
|
severity=classification["severity"],
|
||||||
auth_status=entry["auth_status"],
|
auth_status=entry["auth_status"],
|
||||||
user_identifier=entry.get("user_identifier"),
|
user_identifier=entry.get("user_identifier"),
|
||||||
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Failed to write security access log batch (%d entries)", len(batch))
|
self._last_error_at = datetime.now(timezone.utc)
|
||||||
|
self._last_error_type = type(exc).__name__
|
||||||
|
if attempt + 1 < WRITE_ATTEMPTS:
|
||||||
|
self._retry_count += 1
|
||||||
|
logger.warning(
|
||||||
|
"Security access log batch write failed; retrying (%d entries)",
|
||||||
|
len(batch),
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
continue
|
||||||
|
self._failed_batches += 1
|
||||||
|
self._failed_entries += len(batch)
|
||||||
|
logger.exception(
|
||||||
|
"Failed to write security access log batch after retries (%d entries)",
|
||||||
|
len(batch),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
self._written_entries += len(batch)
|
||||||
|
self._last_success_at = datetime.now(timezone.utc)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def _drain(self) -> None:
|
def stats(self) -> dict[str, Any]:
|
||||||
batch: list[dict] = []
|
return {
|
||||||
while not self._queue.empty():
|
"running": bool(self._task and not self._task.done()),
|
||||||
try:
|
"stopping": self._stopping,
|
||||||
batch.append(self._queue.get_nowait())
|
"queue_size": self._queue.qsize(),
|
||||||
except asyncio.QueueEmpty:
|
"queue_capacity": self._queue.maxsize,
|
||||||
break
|
"accepted_entries": self._accepted_entries,
|
||||||
if batch:
|
"written_entries": self._written_entries,
|
||||||
await self._write_batch(batch)
|
"dropped_entries": self._dropped_entries,
|
||||||
|
"failed_batches": self._failed_batches,
|
||||||
|
"failed_entries": self._failed_entries,
|
||||||
|
"retry_count": self._retry_count,
|
||||||
|
"started_at": self._started_at.isoformat() if self._started_at else None,
|
||||||
|
"last_success_at": self._last_success_at.isoformat() if self._last_success_at else None,
|
||||||
|
"last_error_at": self._last_error_at.isoformat() if self._last_error_at else None,
|
||||||
|
"last_error_type": self._last_error_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_writer: SecurityAccessLogWriter | None = None
|
_writer: SecurityAccessLogWriter | None = None
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Security event classification shared by writers and monitoring APIs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.ip_location import IpLocation
|
||||||
|
|
||||||
|
|
||||||
|
SENSITIVE_PROBE_MARKERS = (
|
||||||
|
"/.env",
|
||||||
|
".env",
|
||||||
|
"/.git",
|
||||||
|
".git/config",
|
||||||
|
"backup",
|
||||||
|
"config.php",
|
||||||
|
"wp-config",
|
||||||
|
"database.yml",
|
||||||
|
)
|
||||||
|
|
||||||
|
def classify_security_event(
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
status_code: int,
|
||||||
|
auth_status: str,
|
||||||
|
ip_location: IpLocation | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
normalized_path = (path or "").lower()
|
||||||
|
if any(marker in normalized_path for marker in SENSITIVE_PROBE_MARKERS):
|
||||||
|
return {"category": "PROBE", "severity": "CRITICAL"}
|
||||||
|
if status_code >= 500:
|
||||||
|
return {"category": "SERVER_ERROR", "severity": "HIGH"}
|
||||||
|
if auth_status == "INVALID_TOKEN":
|
||||||
|
return {"category": "INVALID_TOKEN", "severity": "MEDIUM"}
|
||||||
|
if auth_status == "ANONYMOUS" and status_code in {401, 403}:
|
||||||
|
return {"category": "ANONYMOUS_API", "severity": "MEDIUM"}
|
||||||
|
if status_code == 404:
|
||||||
|
return {"category": "NOT_FOUND_NOISE", "severity": "LOW"}
|
||||||
|
return {"category": "OTHER", "severity": "LOW"}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Hourly source-location aggregation and timeline reads."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.permission_access_log import PermissionAccessLog
|
||||||
|
from app.models.security_access_log import SecurityAccessLog
|
||||||
|
from app.models.source_location_snapshot import SourceLocationSnapshot
|
||||||
|
from app.services.geo_location_metadata import resolve_geo_location_metadata
|
||||||
|
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
|
||||||
|
from app.services.ip_location import resolve_ip_location
|
||||||
|
|
||||||
|
logger = logging.getLogger("ctms.source_location_aggregator")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_datetime(value: datetime) -> datetime:
|
||||||
|
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_hash(kind: str, value: str | None) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
payload = f"source-location:{kind}:{value}".encode("utf-8")
|
||||||
|
return hmac.new(settings.JWT_SECRET_KEY.encode("utf-8"), payload, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def aggregate_source_location_hour(bucket_start: datetime, bucket_end: datetime) -> int:
|
||||||
|
bucket_start = _normalize_datetime(bucket_start)
|
||||||
|
bucket_end = _normalize_datetime(bucket_end)
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
matching_security_request = select(SecurityAccessLog.id).where(
|
||||||
|
PermissionAccessLog.request_id.is_not(None),
|
||||||
|
SecurityAccessLog.request_id == PermissionAccessLog.request_id,
|
||||||
|
).exists()
|
||||||
|
permission_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
PermissionAccessLog.ip_address,
|
||||||
|
PermissionAccessLog.user_id,
|
||||||
|
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed_count"),
|
||||||
|
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||||||
|
func.min(PermissionAccessLog.created_at).label("first_seen_at"),
|
||||||
|
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
PermissionAccessLog.created_at >= bucket_start,
|
||||||
|
PermissionAccessLog.created_at < bucket_end,
|
||||||
|
PermissionAccessLog.ip_address.is_not(None),
|
||||||
|
~matching_security_request,
|
||||||
|
)
|
||||||
|
.group_by(PermissionAccessLog.ip_address, PermissionAccessLog.user_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
security_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
SecurityAccessLog.client_ip,
|
||||||
|
SecurityAccessLog.user_identifier,
|
||||||
|
func.count().filter(SecurityAccessLog.status_code < 400).label("allowed_count"),
|
||||||
|
func.count().filter(SecurityAccessLog.status_code >= 400).label("denied_count"),
|
||||||
|
func.count()
|
||||||
|
.filter(
|
||||||
|
SecurityAccessLog.category.is_not(None),
|
||||||
|
~SecurityAccessLog.category.in_(("OTHER", "NOT_FOUND_NOISE")),
|
||||||
|
)
|
||||||
|
.label("security_event_count"),
|
||||||
|
func.count()
|
||||||
|
.filter(SecurityAccessLog.severity.in_(("HIGH", "CRITICAL")))
|
||||||
|
.label("high_risk_count"),
|
||||||
|
func.count()
|
||||||
|
.filter(
|
||||||
|
SecurityAccessLog.status_code >= 400,
|
||||||
|
SecurityAccessLog.auth_status.in_(("INVALID_TOKEN", "ANONYMOUS")),
|
||||||
|
)
|
||||||
|
.label("auth_failure_count"),
|
||||||
|
func.min(SecurityAccessLog.created_at).label("first_seen_at"),
|
||||||
|
func.max(SecurityAccessLog.created_at).label("last_seen_at"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
SecurityAccessLog.created_at >= bucket_start,
|
||||||
|
SecurityAccessLog.created_at < bucket_end,
|
||||||
|
SecurityAccessLog.client_ip.is_not(None),
|
||||||
|
)
|
||||||
|
.group_by(SecurityAccessLog.client_ip, SecurityAccessLog.user_identifier)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
aggregates: dict[tuple[str, str], dict] = {}
|
||||||
|
|
||||||
|
def merge_row(
|
||||||
|
ip_address: str,
|
||||||
|
user_identity: str,
|
||||||
|
allowed_count: int,
|
||||||
|
denied_count: int,
|
||||||
|
first_seen_at: datetime,
|
||||||
|
last_seen_at: datetime,
|
||||||
|
*,
|
||||||
|
security_event_count: int = 0,
|
||||||
|
high_risk_count: int = 0,
|
||||||
|
auth_failure_count: int = 0,
|
||||||
|
) -> None:
|
||||||
|
key = (ip_address, user_identity)
|
||||||
|
row = aggregates.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"ip_address": ip_address,
|
||||||
|
"user_identity": user_identity,
|
||||||
|
"allowed_count": 0,
|
||||||
|
"denied_count": 0,
|
||||||
|
"security_event_count": 0,
|
||||||
|
"high_risk_count": 0,
|
||||||
|
"auth_failure_count": 0,
|
||||||
|
"first_seen_at": _normalize_datetime(first_seen_at),
|
||||||
|
"last_seen_at": _normalize_datetime(last_seen_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row["allowed_count"] += int(allowed_count or 0)
|
||||||
|
row["denied_count"] += int(denied_count or 0)
|
||||||
|
row["security_event_count"] += int(security_event_count or 0)
|
||||||
|
row["high_risk_count"] += int(high_risk_count or 0)
|
||||||
|
row["auth_failure_count"] += int(auth_failure_count or 0)
|
||||||
|
row["first_seen_at"] = min(row["first_seen_at"], _normalize_datetime(first_seen_at))
|
||||||
|
row["last_seen_at"] = max(row["last_seen_at"], _normalize_datetime(last_seen_at))
|
||||||
|
|
||||||
|
for ip_address, user_id, allowed, denied, first_seen, last_seen in permission_rows:
|
||||||
|
merge_row(str(ip_address), str(user_id or ""), allowed, denied, first_seen, last_seen)
|
||||||
|
for ip_address, user_identifier, allowed, denied, security_events, high_risk, auth_failures, first_seen, last_seen in security_rows:
|
||||||
|
merge_row(
|
||||||
|
str(ip_address),
|
||||||
|
str(user_identifier or ""),
|
||||||
|
allowed,
|
||||||
|
denied,
|
||||||
|
first_seen,
|
||||||
|
last_seen,
|
||||||
|
security_event_count=security_events,
|
||||||
|
high_risk_count=high_risk,
|
||||||
|
auth_failure_count=auth_failures,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_locations = {
|
||||||
|
ip_address: resolve_ip_location(ip_address)
|
||||||
|
for ip_address, _user_identity in aggregates
|
||||||
|
}
|
||||||
|
resolved_metadata = {
|
||||||
|
ip_address: resolve_geo_location_metadata(ip_info)
|
||||||
|
for ip_address, ip_info in resolved_locations.items()
|
||||||
|
}
|
||||||
|
missing_coordinate_ips = [
|
||||||
|
ip_address
|
||||||
|
for ip_address, metadata in resolved_metadata.items()
|
||||||
|
if metadata.accuracy_level != "private"
|
||||||
|
and (metadata.longitude is None or metadata.latitude is None)
|
||||||
|
]
|
||||||
|
external_locations = await resolve_external_ip_locations(missing_coordinate_ips)
|
||||||
|
for ip_address, external_location in external_locations.items():
|
||||||
|
resolved_locations[ip_address] = external_location.merge_ip_location(resolved_locations[ip_address])
|
||||||
|
resolved_metadata[ip_address] = external_location.to_metadata()
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
delete(SourceLocationSnapshot).where(SourceLocationSnapshot.bucket_time == bucket_start)
|
||||||
|
)
|
||||||
|
for row in aggregates.values():
|
||||||
|
ip_info = resolved_locations[row["ip_address"]]
|
||||||
|
metadata = resolved_metadata[row["ip_address"]]
|
||||||
|
longitude = metadata.longitude
|
||||||
|
latitude = metadata.latitude
|
||||||
|
country = metadata.country or ip_info.country
|
||||||
|
location = " / ".join(
|
||||||
|
part for part in [country, ip_info.province, ip_info.city] if part
|
||||||
|
) or ip_info.location or "未知"
|
||||||
|
session.add(
|
||||||
|
SourceLocationSnapshot(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
bucket_time=bucket_start,
|
||||||
|
ip_hash=_identity_hash("ip", row["ip_address"]),
|
||||||
|
user_hash=_identity_hash("user", row["user_identity"]),
|
||||||
|
country=country,
|
||||||
|
country_code=metadata.country_code,
|
||||||
|
province=ip_info.province,
|
||||||
|
region_code=metadata.region_code,
|
||||||
|
city=ip_info.city,
|
||||||
|
isp=ip_info.isp,
|
||||||
|
location=location,
|
||||||
|
longitude=longitude,
|
||||||
|
latitude=latitude,
|
||||||
|
accuracy_level=metadata.accuracy_level,
|
||||||
|
allowed_count=row["allowed_count"],
|
||||||
|
denied_count=row["denied_count"],
|
||||||
|
security_event_count=row["security_event_count"],
|
||||||
|
high_risk_count=row["high_risk_count"],
|
||||||
|
auth_failure_count=row["auth_failure_count"],
|
||||||
|
first_seen_at=row["first_seen_at"],
|
||||||
|
last_seen_at=row["last_seen_at"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return len(aggregates)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_source_location_timeline(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
start_at: datetime,
|
||||||
|
end_at: datetime,
|
||||||
|
granularity: Literal["hour", "day"],
|
||||||
|
) -> list[dict]:
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(SourceLocationSnapshot)
|
||||||
|
.where(
|
||||||
|
SourceLocationSnapshot.bucket_time >= start_at,
|
||||||
|
SourceLocationSnapshot.bucket_time < end_at,
|
||||||
|
)
|
||||||
|
.order_by(SourceLocationSnapshot.bucket_time)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
buckets: dict[datetime, dict] = defaultdict(
|
||||||
|
lambda: {
|
||||||
|
"allowed_count": 0,
|
||||||
|
"denied_count": 0,
|
||||||
|
"security_event_count": 0,
|
||||||
|
"high_risk_count": 0,
|
||||||
|
"ip_hashes": set(),
|
||||||
|
"user_hashes": set(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
bucket = _normalize_datetime(row.bucket_time)
|
||||||
|
if granularity == "day":
|
||||||
|
bucket = bucket.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
else:
|
||||||
|
bucket = bucket.replace(minute=0, second=0, microsecond=0)
|
||||||
|
item = buckets[bucket]
|
||||||
|
item["allowed_count"] += row.allowed_count
|
||||||
|
item["denied_count"] += row.denied_count
|
||||||
|
item["security_event_count"] += row.security_event_count
|
||||||
|
item["high_risk_count"] += row.high_risk_count
|
||||||
|
item["ip_hashes"].add(row.ip_hash)
|
||||||
|
if row.user_hash:
|
||||||
|
item["user_hashes"].add(row.user_hash)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"bucket_time": bucket.isoformat(),
|
||||||
|
"total_count": item["allowed_count"] + item["denied_count"],
|
||||||
|
"allowed_count": item["allowed_count"],
|
||||||
|
"denied_count": item["denied_count"],
|
||||||
|
"security_event_count": item["security_event_count"],
|
||||||
|
"high_risk_count": item["high_risk_count"],
|
||||||
|
"unique_ip_count": len(item["ip_hashes"]),
|
||||||
|
"unique_user_count": len(item["user_hashes"]),
|
||||||
|
}
|
||||||
|
for bucket, item in sorted(buckets.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def run_hourly_source_location_aggregation(stop_event: asyncio.Event) -> None:
|
||||||
|
logger.info("Source location aggregator started")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
completed_hour = now.replace(minute=0, second=0, microsecond=0)
|
||||||
|
try:
|
||||||
|
await aggregate_source_location_hour(completed_hour - timedelta(hours=1), completed_hour)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to backfill source location snapshot for %s", completed_hour)
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stop_event.wait(), timeout=(next_hour - now).total_seconds())
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await aggregate_source_location_hour(next_hour - timedelta(hours=1), next_hour)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to aggregate source locations for %s", next_hour)
|
||||||
|
|
||||||
|
logger.info("Source location aggregator stopped")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user